Initial commit: CI Agent competitive-intelligence monitoring app

FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring,
source collection, LLM-based change analysis, enrichment, and account
security (Turnstile, escalating lockout, email verification).
This commit is contained in:
2026-08-05 10:48:20 -04:00
commit 1a4c80958f
365 changed files with 43541 additions and 0 deletions
View File
+173
View File
@@ -0,0 +1,173 @@
"""End-to-end proof that a real monitoring run wires all the way through to
a delivered notification: baseline run -> content changes -> second run's
DetectedChange crosses the company's (LOW) severity threshold -> Alert
created -> dispatched to the registered email destination -> smtplib
(mocked) actually gets called. Everything else is the real HTTP API +
Celery-eager pipeline, exactly as a user would trigger it from the
dashboard; only DNS and the outbound SMTP socket are mocked."""
from __future__ import annotations
import json
import uuid
from unittest.mock import patch
import httpx
import respx
def _register_and_login(client) -> dict[str, str]:
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
def _mock_empty_discovery():
respx.get("https://api.github.com/search/users").mock(
return_value=httpx.Response(200, text=json.dumps({"items": []}))
)
respx.get("https://www.sec.gov/cgi-bin/browse-edgar").mock(
return_value=httpx.Response(
200, text='<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>'
)
)
# RSS (Google News) and GOV_CONTRACT (USASpending) are always discovered
# unconditionally - see discovery_service.py's _PREVIEWABLE_TYPES - so
# their collect() calls need mocking here too.
respx.get(url__regex=r"https://news\.google\.com/rss/search.*").mock(
return_value=httpx.Response(
200,
text=(
'<?xml version="1.0"?><rss version="2.0"><channel><title>Google News</title>'
"<item><title>Test</title><link>https://example.com/news</link>"
"<description>Test news item</description></item></channel></rss>"
),
)
)
respx.post("https://api.usaspending.gov/api/v2/search/spending_by_award/").mock(
return_value=httpx.Response(200, json={"results": []})
)
def test_monitoring_run_detecting_a_change_produces_alert_and_email(client, monkeypatch):
sent_emails = []
class FakeSmtp:
def __init__(self, host, port, timeout=10):
pass
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def starttls(self):
pass
def login(self, username, password):
pass
def sendmail(self, from_addr, to_addrs, message):
sent_emails.append({"to": to_addrs, "message": message})
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FakeSmtp)
headers = _register_and_login(client)
company = client.post(
"/api/v1/companies",
json={
"name": f"Acme Mobility {uuid.uuid4().hex[:6]}",
"frequency_type": "weekly",
"severity_threshold": "low",
},
headers=headers,
).json()
client.post(
f"/api/v1/companies/{company['id']}/sources",
json={
"source_type": "custom_url",
"name": "Pricing",
"base_url": "https://example.com/pricing",
},
headers=headers,
)
client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "[email protected]",
"minimum_severity": "low",
"company_ids": [company["id"]],
},
headers=headers,
)
baseline_html = (
"<html><head><title>Pricing</title></head><body>"
"<article><h1>Pricing</h1><p>The base plan is $49/month.</p></article>"
"</body></html>"
)
changed_html = (
"<html><head><title>Pricing</title></head><body>"
"<article><h1>Pricing</h1><p>The base plan is $99/month.</p></article>"
"</body></html>"
)
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
with respx.mock:
_mock_empty_discovery()
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
respx.get("https://example.com/pricing").mock(
return_value=httpx.Response(200, html=baseline_html)
)
baseline_run = client.post(
f"/api/v1/companies/{company['id']}/run", headers=headers
).json()
with respx.mock:
_mock_empty_discovery()
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
respx.get("https://example.com/pricing").mock(
return_value=httpx.Response(200, html=changed_html)
)
second_run = client.post(
f"/api/v1/companies/{company['id']}/run", headers=headers
).json()
assert (
client.get(f"/api/v1/runs/{baseline_run['id']}", headers=headers).json()["status"]
== "successful"
)
assert (
client.get(f"/api/v1/runs/{second_run['id']}", headers=headers).json()["status"]
== "successful"
)
alerts = client.get(
"/api/v1/alerts", headers=headers, params={"company_id": company["id"]}
).json()
assert len(alerts) == 1
alert = alerts[0]
assert alert["severity"] in {"low", "medium", "high", "critical"}
detail = client.get(f"/api/v1/alerts/{alert['id']}", headers=headers).json()
assert len(detail["deliveries"]) == 1
assert detail["deliveries"][0]["status"] == "sent"
# Registration now also sends a verification-code email through this
# same SMTP path (no RESEND_API_KEY configured in tests, so
# security_email_service falls back to SmtpEmailProvider) - filter to
# the alert email specifically rather than assuming it's the only one.
alert_emails = [e for e in sent_emails if e["to"] == ["[email protected]"]]
assert len(alert_emails) == 1
assert alert["title"] in alert_emails[0]["message"]
+200
View File
@@ -0,0 +1,200 @@
"""Alerts API: ownership isolation, filters, and read/resolve mutations.
Alerts are only ever created internally by alert_service (never via a user
POST), so tests seed rows directly through db_session against the same
user_id the HTTP client is authenticated as (looked up via GET /auth/me)."""
from __future__ import annotations
import uuid
import pytest
from app.models.alert import Alert
from app.models.company import Company
from app.models.detected_change import DetectedChange
from app.models.enums import ChangeType, MonitoringRunTrigger, SeverityLevel, SourceType
from app.models.monitoring_run import MonitoringRun
from app.models.snapshot import Snapshot
from app.models.source import Source
def _register_and_login(client) -> tuple[dict[str, str], uuid.UUID]:
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
user_id = uuid.UUID(client.get("/api/v1/auth/me", headers=headers).json()["id"])
return headers, user_id
async def _make_alert(
db_session,
user_id: uuid.UUID,
*,
severity: SeverityLevel = SeverityLevel.HIGH,
read: bool = False,
resolved: bool = False,
) -> Alert:
company = Company(
id=uuid.uuid4(), user_id=user_id, name="Acme Mobility", slug=f"acme-{uuid.uuid4().hex[:6]}"
)
db_session.add(company)
await db_session.flush()
source = Source(
company_id=company.id,
source_type=SourceType.WEBSITE,
name="Website",
base_url="https://acme.example",
)
db_session.add(source)
await db_session.flush()
run = MonitoringRun(company_id=company.id, trigger_type=MonitoringRunTrigger.MANUAL)
db_session.add(run)
await db_session.flush()
snapshot = Snapshot(
company_id=company.id,
source_id=source.id,
snapshot_type=source.source_type.value,
hash="hash1",
structured_summary={},
text_summary="",
monitoring_run_id=run.id,
)
db_session.add(snapshot)
await db_session.flush()
change = DetectedChange(
company_id=company.id,
source_id=source.id,
monitoring_run_id=run.id,
current_snapshot_id=snapshot.id,
change_type=ChangeType.NEW_DOCUMENT,
raw_diff={},
significance_score=0.6,
confidence_score=0.75,
severity=severity,
summary="Change detected",
)
db_session.add(change)
await db_session.flush()
alert = Alert(
company_id=company.id,
detected_change_id=change.id,
user_id=user_id,
title="New hire announced",
summary="A new VP of Engineering was announced.",
why_it_matters="Signals a scaling push.",
severity=severity,
confidence=0.75,
read=read,
resolved=resolved,
)
db_session.add(alert)
await db_session.commit()
await db_session.refresh(alert)
return alert
@pytest.mark.asyncio
async def test_list_alerts_scoped_to_owner(client, db_session):
owner_headers, owner_id = _register_and_login(client)
_other_headers, other_id = _register_and_login(client)
await _make_alert(db_session, owner_id)
await _make_alert(db_session, other_id)
resp = client.get("/api/v1/alerts", headers=owner_headers)
assert resp.status_code == 200
body = resp.json()
assert len(body) == 1
@pytest.mark.asyncio
async def test_list_alerts_filters_by_severity(client, db_session):
headers, user_id = _register_and_login(client)
await _make_alert(db_session, user_id, severity=SeverityLevel.CRITICAL)
await _make_alert(db_session, user_id, severity=SeverityLevel.LOW)
resp = client.get("/api/v1/alerts", headers=headers, params={"severity": "critical"})
assert resp.status_code == 200
body = resp.json()
assert len(body) == 1
assert body[0]["severity"] == "critical"
@pytest.mark.asyncio
async def test_list_alerts_filters_by_read_and_resolved(client, db_session):
headers, user_id = _register_and_login(client)
await _make_alert(db_session, user_id, read=True, resolved=False)
await _make_alert(db_session, user_id, read=False, resolved=False)
resp = client.get("/api/v1/alerts", headers=headers, params={"read": "false"})
assert resp.status_code == 200
body = resp.json()
assert len(body) == 1
assert body[0]["read"] is False
@pytest.mark.asyncio
async def test_get_alert_detail_includes_deliveries(client, db_session):
headers, user_id = _register_and_login(client)
alert = await _make_alert(db_session, user_id)
resp = client.get(f"/api/v1/alerts/{alert.id}", headers=headers)
assert resp.status_code == 200
body = resp.json()
assert body["id"] == str(alert.id)
assert body["deliveries"] == []
@pytest.mark.asyncio
async def test_get_alert_not_owned_returns_404(client, db_session):
_owner_headers, owner_id = _register_and_login(client)
other_headers, _other_id = _register_and_login(client)
alert = await _make_alert(db_session, owner_id)
resp = client.get(f"/api/v1/alerts/{alert.id}", headers=other_headers)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_mark_alert_read(client, db_session):
headers, user_id = _register_and_login(client)
alert = await _make_alert(db_session, user_id, read=False)
resp = client.post(f"/api/v1/alerts/{alert.id}/read", headers=headers)
assert resp.status_code == 200
assert resp.json()["read"] is True
@pytest.mark.asyncio
async def test_resolve_alert(client, db_session):
headers, user_id = _register_and_login(client)
alert = await _make_alert(db_session, user_id, resolved=False)
resp = client.post(f"/api/v1/alerts/{alert.id}/resolve", headers=headers)
assert resp.status_code == 200
assert resp.json()["resolved"] is True
@pytest.mark.asyncio
async def test_patch_alert_updates_both_flags(client, db_session):
headers, user_id = _register_and_login(client)
alert = await _make_alert(db_session, user_id)
resp = client.patch(
f"/api/v1/alerts/{alert.id}", json={"read": True, "resolved": True}, headers=headers
)
assert resp.status_code == 200
body = resp.json()
assert body["read"] is True
assert body["resolved"] is True
+254
View File
@@ -0,0 +1,254 @@
"""Auth flow tests. Runs under AUTH_MODE=jwt (the suite default) except where
`local_mode_client` explicitly exercises the local-dev-user path."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from fastapi.testclient import TestClient
from app.core.config import get_settings
from app.main import app
from app.models.enums import SecurityEventType
from app.models.user import LOCAL_DEV_USER_ID
from app.repositories.user_security_event_repository import UserSecurityEventRepository
def _unique_email() -> str:
return f"user-{uuid.uuid4().hex[:12]}@example.com"
def test_register_then_login(client):
email = _unique_email()
register_resp = client.post(
"/api/v1/auth/register",
json={
"email": email,
"password": "correct-horse-1",
"display_name": "Test User",
},
)
assert register_resp.status_code == 201
body = register_resp.json()
assert body["email"] == email
assert "password" not in body
assert "password_hash" not in body
login_resp = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
)
assert login_resp.status_code == 200
tokens = login_resp.json()
assert tokens["access_token"]
assert tokens["refresh_token"]
assert tokens["token_type"] == "bearer"
def test_register_duplicate_email_conflicts(client):
email = _unique_email()
payload = {"email": email, "password": "correct-horse-1", "display_name": "Test User"}
first = client.post("/api/v1/auth/register", json=payload)
assert first.status_code == 201
second = client.post("/api/v1/auth/register", json=payload)
assert second.status_code == 409
def test_register_rejects_weak_password(client):
resp = client.post(
"/api/v1/auth/register",
json={"email": _unique_email(), "password": "allletters", "display_name": "Test User"},
)
assert resp.status_code == 422
def test_login_wrong_password_rejected(client):
email = _unique_email()
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
)
resp = client.post("/api/v1/auth/login", json={"email": email, "password": "wrong-password-1"})
assert resp.status_code == 401
def test_me_requires_bearer_token(client):
resp = client.get("/api/v1/auth/me")
assert resp.status_code == 401
def test_me_returns_current_user(client):
email = _unique_email()
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
)
login = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
resp = client.get(
"/api/v1/auth/me", headers={"Authorization": f"Bearer {login['access_token']}"}
)
assert resp.status_code == 200
body = resp.json()
assert body["email"] == email
assert body["auth_mode"] == "jwt"
def test_refresh_rotates_and_invalidates_old_token(client):
email = _unique_email()
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
)
login = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
refresh_resp = client.post(
"/api/v1/auth/refresh", json={"refresh_token": login["refresh_token"]}
)
assert refresh_resp.status_code == 200
new_tokens = refresh_resp.json()
assert new_tokens["refresh_token"] != login["refresh_token"]
# The old refresh token was rotated out and must not be reusable.
reuse_resp = client.post("/api/v1/auth/refresh", json={"refresh_token": login["refresh_token"]})
assert reuse_resp.status_code == 401
def test_logout_revokes_refresh_token(client):
email = _unique_email()
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
)
login = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
logout_resp = client.post("/api/v1/auth/logout", json={"refresh_token": login["refresh_token"]})
assert logout_resp.status_code == 204
reuse_resp = client.post("/api/v1/auth/refresh", json={"refresh_token": login["refresh_token"]})
assert reuse_resp.status_code == 401
def test_local_mode_me_returns_fixed_dev_user_without_token(local_mode_client):
resp = local_mode_client.get("/api/v1/auth/me")
assert resp.status_code == 200
body = resp.json()
assert body["email"] == "[email protected]"
assert body["auth_mode"] == "local"
def test_register_login_available_even_in_local_mode(local_mode_client):
"""Registering/logging in a real account must always be possible,
regardless of AUTH_MODE - the loopback convenience only affects whether
a request can skip auth entirely, not whether real accounts exist."""
email = _unique_email()
register_resp = local_mode_client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "X"},
)
assert register_resp.status_code == 201
login_resp = local_mode_client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
)
assert login_resp.status_code == 200
assert login_resp.json()["access_token"]
def test_security_events_requires_auth(client):
resp = client.get("/api/v1/auth/security-events")
assert resp.status_code == 401
def _login_success_count(events: list[dict]) -> int:
return sum(1 for e in events if e["event_type"] == "login_success")
def test_local_dev_sign_in_is_logged_to_account_activity(local_mode_client):
"""The local-dev bypass has no real login step - hitting any
authenticated endpoint (here /auth/me, the same check the frontend
performs on app load) must still show up as a sign-in."""
local_mode_client.get("/api/v1/auth/me")
events = local_mode_client.get("/api/v1/auth/security-events").json()
assert any(e["event_type"] == "login_success" for e in events)
def test_local_dev_sign_in_is_not_logged_twice_within_cooldown(local_mode_client):
"""Every authenticated request runs get_or_create_local_user - without
a cooldown, browsing the app at all would flood Account activity with
one login_success per request."""
local_mode_client.get("/api/v1/auth/me")
before = _login_success_count(local_mode_client.get("/api/v1/auth/security-events").json())
local_mode_client.get("/api/v1/auth/me")
after = _login_success_count(local_mode_client.get("/api/v1/auth/security-events").json())
assert after == before
async def test_local_dev_sign_in_logs_again_after_cooldown_expires(local_mode_client, db_session):
local_mode_client.get("/api/v1/auth/me")
before = _login_success_count(local_mode_client.get("/api/v1/auth/security-events").json())
event_repo = UserSecurityEventRepository(db_session)
last_login = await event_repo.most_recent_of_type(
LOCAL_DEV_USER_ID, SecurityEventType.LOGIN_SUCCESS
)
assert last_login is not None
last_login.created_at = datetime.now(UTC) - timedelta(minutes=31)
await db_session.commit()
local_mode_client.get("/api/v1/auth/me")
after = _login_success_count(local_mode_client.get("/api/v1/auth/security-events").json())
assert after == before + 1
def test_security_events_returns_own_login_events_only(client):
email_a = _unique_email()
client.post(
"/api/v1/auth/register",
json={"email": email_a, "password": "correct-horse-1", "display_name": "A"},
)
login_a = client.post(
"/api/v1/auth/login", json={"email": email_a, "password": "correct-horse-1"}
).json()
email_b = _unique_email()
client.post(
"/api/v1/auth/register",
json={"email": email_b, "password": "correct-horse-1", "display_name": "B"},
)
client.post("/api/v1/auth/login", json={"email": email_b, "password": "correct-horse-1"})
resp = client.get(
"/api/v1/auth/security-events",
headers={"Authorization": f"Bearer {login_a['access_token']}"},
)
assert resp.status_code == 200
events = resp.json()
event_types = {e["event_type"] for e in events}
# registration writes email_verification_sent, login writes login_success -
# both belong to user A only, never user B's events.
assert event_types == {"email_verification_sent", "login_success"}
def test_local_mode_setting_alone_does_not_bypass_auth_for_non_loopback_callers():
"""AUTH_MODE=local is not a blanket switch - a request that isn't
actually from loopback (e.g. a LAN/WAN caller, or here Starlette's
TestClient default fake peer) still needs a real bearer token."""
settings = get_settings().model_copy(update={"auth_mode": "local"})
app.dependency_overrides[get_settings] = lambda: settings
try:
with TestClient(app) as non_loopback_client: # default peer: ("testclient", 50000)
resp = non_loopback_client.get("/api/v1/auth/me")
assert resp.status_code == 401
finally:
app.dependency_overrides.pop(get_settings, None)
+254
View File
@@ -0,0 +1,254 @@
"""Company CRUD, monitor configuration, and ownership isolation tests."""
from __future__ import annotations
import uuid
def _register_and_login(client) -> dict[str, str]:
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
def _create_company(client, headers, **overrides):
payload = {
"name": "Acme Mobility Systems",
"official_website": "acme-mobility.example.com",
"monitoring_focus": "EV manufacturing expansion",
"competitor_names": ["Rival Motors"],
"alias_names": ["Acme"],
"frequency_type": "weekly",
**overrides,
}
return client.post("/api/v1/companies", json=payload, headers=headers)
def test_create_company_normalizes_website_and_sets_defaults(client):
headers = _register_and_login(client)
resp = _create_company(client, headers)
assert resp.status_code == 201
body = resp.json()
assert body["official_website"] == "https://acme-mobility.example.com"
assert body["slug"] == "acme-mobility-systems"
assert body["status"] == "active"
assert body["aliases"] == ["Acme"]
assert body["competitors"] == ["Rival Motors"]
assert body["monitor_configuration"]["frequency_type"] == "weekly"
assert body["monitor_configuration"]["enabled"] is True
assert body["monitor_configuration"]["next_run"] is not None
def test_duplicate_company_names_get_distinct_slugs(client):
headers = _register_and_login(client)
first = _create_company(client, headers).json()
second = _create_company(client, headers).json()
assert first["slug"] != second["slug"]
def test_duplicate_company_names_get_a_unique_display_name(client):
headers = _register_and_login(client)
first = _create_company(client, headers).json()
second = _create_company(client, headers).json()
third = _create_company(client, headers).json()
assert first["name"] == "Acme Mobility Systems"
assert second["name"] == "Acme Mobility Systems (2)"
assert third["name"] == "Acme Mobility Systems (3)"
def test_company_name_uniqueness_is_case_insensitive(client):
headers = _register_and_login(client)
first = _create_company(client, headers, name="Stripe").json()
second = _create_company(client, headers, name="STRIPE").json()
assert first["name"] == "Stripe"
assert second["name"] == "STRIPE (2)"
def test_company_name_uniqueness_is_scoped_per_user(client):
owner_headers = _register_and_login(client)
other_headers = _register_and_login(client)
owner_company = _create_company(client, owner_headers).json()
other_company = _create_company(client, other_headers).json()
# Two different users can each have a company with the exact same name -
# uniqueness is per-user, not global.
assert owner_company["name"] == other_company["name"] == "Acme Mobility Systems"
def test_company_list_and_detail_are_scoped_to_owner(client):
owner_headers = _register_and_login(client)
other_headers = _register_and_login(client)
created = _create_company(client, owner_headers).json()
owner_list = client.get("/api/v1/companies", headers=owner_headers).json()
assert any(c["id"] == created["id"] for c in owner_list)
other_list = client.get("/api/v1/companies", headers=other_headers).json()
assert all(c["id"] != created["id"] for c in other_list)
other_detail = client.get(f"/api/v1/companies/{created['id']}", headers=other_headers)
assert other_detail.status_code == 404
owner_detail = client.get(f"/api/v1/companies/{created['id']}", headers=owner_headers)
assert owner_detail.status_code == 200
def test_pause_and_resume_company_toggles_monitor_enabled(client):
headers = _register_and_login(client)
company = _create_company(client, headers).json()
paused = client.post(f"/api/v1/companies/{company['id']}/pause", headers=headers).json()
assert paused["status"] == "paused"
assert paused["monitor_configuration"]["enabled"] is False
resumed = client.post(f"/api/v1/companies/{company['id']}/resume", headers=headers).json()
assert resumed["status"] == "active"
assert resumed["monitor_configuration"]["enabled"] is True
def test_update_company_replaces_aliases_and_competitors(client):
headers = _register_and_login(client)
company = _create_company(client, headers).json()
resp = client.patch(
f"/api/v1/companies/{company['id']}",
json={"alias_names": ["New Alias"], "competitor_names": []},
headers=headers,
)
assert resp.status_code == 200
body = resp.json()
assert body["aliases"] == ["New Alias"]
assert body["competitors"] == []
def test_delete_company_removes_it(client):
headers = _register_and_login(client)
company = _create_company(client, headers).json()
resp = client.delete(f"/api/v1/companies/{company['id']}", headers=headers)
assert resp.status_code == 204
resp = client.get(f"/api/v1/companies/{company['id']}", headers=headers)
assert resp.status_code == 404
def test_monitor_configuration_get_and_patch(client):
headers = _register_and_login(client)
company = _create_company(client, headers).json()
resp = client.get(f"/api/v1/companies/{company['id']}/monitor", headers=headers)
assert resp.status_code == 200
assert resp.json()["frequency_type"] == "weekly"
patch_resp = client.patch(
f"/api/v1/companies/{company['id']}/monitor",
json={"frequency_type": "daily"},
headers=headers,
)
assert patch_resp.status_code == 200
assert patch_resp.json()["frequency_type"] == "daily"
def test_custom_schedule_below_minimum_interval_is_rejected(client, settings):
headers = _register_and_login(client)
resp = _create_company(
client,
headers,
name="Too Frequent Co",
frequency_type="custom",
interval_minutes=settings.minimum_monitoring_interval_minutes - 1,
)
assert resp.status_code == 400
def test_custom_schedule_with_valid_interval_is_accepted(client, settings):
headers = _register_and_login(client)
resp = _create_company(
client,
headers,
name="Custom Interval Co",
frequency_type="custom",
interval_minutes=settings.minimum_monitoring_interval_minutes + 30,
)
assert resp.status_code == 201
assert resp.json()["monitor_configuration"]["interval_minutes"] == (
settings.minimum_monitoring_interval_minutes + 30
)
def test_custom_schedule_requires_interval_or_cron(client):
headers = _register_and_login(client)
resp = _create_company(client, headers, name="No Schedule Co", frequency_type="custom")
assert resp.status_code == 400
def test_company_creation_requires_authentication(client):
resp = client.post("/api/v1/companies", json={"name": "No Auth Co"})
assert resp.status_code == 401
def test_max_companies_per_user_enforced(client, settings, monkeypatch):
headers = _register_and_login(client)
from app.core import config as config_module
limited_settings = settings.model_copy(update={"max_companies_per_user": 1})
from app.main import app
app.dependency_overrides[config_module.get_settings] = lambda: limited_settings
try:
first = _create_company(client, headers, name="Company One")
assert first.status_code == 201
second = _create_company(client, headers, name="Company Two")
assert second.status_code == 409
finally:
app.dependency_overrides.pop(config_module.get_settings, None)
def test_create_company_enqueues_enrichment_when_a_key_is_configured(client, settings):
"""The real regression guard is every OTHER test in this suite: none of
them configure NINJAPEAR_API_KEY, so the whole rest of the suite proves
the enqueue is skipped by default (see the "without a key" test below
for the direct assertion)."""
from unittest.mock import patch
from app.core import config as config_module
from app.main import app
key_settings = settings.model_copy(update={"ninjapear_api_key": "test-key"})
app.dependency_overrides[config_module.get_settings] = lambda: key_settings
try:
headers = _register_and_login(client)
with patch("app.tasks.enrichment.enrich_company.delay") as mock_delay:
resp = _create_company(client, headers, name="Enriched Co")
assert resp.status_code == 201
body = resp.json()
mock_delay.assert_called_once_with(body["id"])
assert body["enrichment"] == {
"status": "pending",
"data": {},
"errors": {},
"credits_spent": None,
"fetched_at": None,
}
finally:
app.dependency_overrides.pop(config_module.get_settings, None)
def test_create_company_does_not_enqueue_enrichment_without_a_key(client):
from unittest.mock import patch
headers = _register_and_login(client)
with patch("app.tasks.enrichment.enrich_company.delay") as mock_delay:
resp = _create_company(client, headers, name="Plain Co")
assert resp.status_code == 201
mock_delay.assert_not_called()
assert resp.json()["enrichment"] is None
@@ -0,0 +1,22 @@
"""Correlation-ID middleware: every response echoes an X-Request-ID, reusing
an inbound one from a gateway/client if present rather than always minting a
fresh one - see app/main.py::correlation_id_middleware."""
from __future__ import annotations
def test_response_includes_a_generated_request_id(client):
resp = client.get("/health")
assert "X-Request-ID" in resp.headers
assert len(resp.headers["X-Request-ID"]) > 0
def test_response_reuses_inbound_request_id(client):
resp = client.get("/health", headers={"X-Request-ID": "test-correlation-abc123"})
assert resp.headers["X-Request-ID"] == "test-correlation-abc123"
def test_each_request_gets_a_distinct_generated_id(client):
first = client.get("/health").headers["X-Request-ID"]
second = client.get("/health").headers["X-Request-ID"]
assert first != second
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
import uuid
def _register_and_login(client) -> dict[str, str]:
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
def test_dashboard_analytics_returns_zero_filled_buckets_for_a_new_user(client):
headers = _register_and_login(client)
resp = client.get("/api/v1/dashboard/analytics", headers=headers)
assert resp.status_code == 200
body = resp.json()
assert set(body["changes_by_type"].keys()) == {
"new_document",
"removed_document",
"content_modified",
"price_change",
"leadership_change",
"filing_new",
}
assert all(v == 0 for v in body["changes_by_type"].values())
assert body["recent_signals"] == []
def test_dashboard_analytics_requires_auth(client):
resp = client.get("/api/v1/dashboard/analytics")
assert resp.status_code in (401, 403)
@@ -0,0 +1,95 @@
"""POST /companies/discover: persists nothing, returns a proposed profile,
rate-limited tightly since it costs a real search + LLM call."""
from __future__ import annotations
import json
import uuid
from unittest.mock import patch
import httpx
import respx
from app.core.rate_limit import limiter
def _register_and_login(client) -> dict[str, str]:
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
def _mock_empty_github_sec():
respx.get("https://api.github.com/search/users").mock(
return_value=httpx.Response(200, text=json.dumps({"items": []}))
)
respx.get("https://www.sec.gov/cgi-bin/browse-edgar").mock(
return_value=httpx.Response(
200, text='<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>'
)
)
def test_discover_returns_a_profile_without_persisting_a_company(client):
headers = _register_and_login(client)
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
with respx.mock:
_mock_empty_github_sec()
respx.get("https://acmewidgets.com/robots.txt").mock(return_value=httpx.Response(404))
respx.get("https://acmewidgets.com").mock(
return_value=httpx.Response(200, html="<html><body>Acme Widgets</body></html>")
)
resp = client.post(
"/api/v1/companies/discover",
json={"name": "Acme Widgets"},
headers=headers,
)
assert resp.status_code == 200
body = resp.json()
assert body["name"] == "Acme Widgets"
assert body["official_website"] == "https://acmewidgets.com"
assert "potential_sources" in body
assert "sources_consulted" in body
companies = client.get("/api/v1/companies", headers=headers).json()
assert companies == []
def test_discover_requires_auth(client):
resp = client.post("/api/v1/companies/discover", json={"name": "Acme"})
assert resp.status_code in (401, 403)
def test_discover_enforces_rate_limit(client):
headers = _register_and_login(client)
limiter.enabled = True
try:
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
with respx.mock:
_mock_empty_github_sec()
respx.route(host="acme0.com").mock(return_value=httpx.Response(404))
respx.route(host="acme1.com").mock(return_value=httpx.Response(404))
respx.route(host="acme2.com").mock(return_value=httpx.Response(404))
respx.route(host="acme3.com").mock(return_value=httpx.Response(404))
respx.route(host="acme4.com").mock(return_value=httpx.Response(404))
respx.route(host="acme5.com").mock(return_value=httpx.Response(404))
statuses = [
client.post(
"/api/v1/companies/discover",
json={"name": f"Acme{i}"},
headers=headers,
).status_code
for i in range(6)
]
assert 429 in statuses, f"Expected a 429 among {statuses} after 6 rapid discover calls"
finally:
limiter.enabled = False
@@ -0,0 +1,170 @@
"""enrichment_service.enrich_company: per-section independent failure
handling, the leadership-lookup cap, and overall status derivation."""
from __future__ import annotations
import uuid
import pytest
from app.enrichment.base import (
CompanyDetails,
CompanyFunding,
LeadershipMember,
)
from app.models.company import Company
from app.models.enums import EnrichmentStatus
from app.repositories.company_enrichment_repository import CompanyEnrichmentRepository
from app.services.enrichment_service import enrich_company
class _FakeProvider:
provider_name = "fake"
def __init__(
self, *, leadership: list[LeadershipMember] | None = None, fail: set[str] | None = None
):
self._leadership = leadership or []
self._fail = fail or set()
async def get_company_details(self, name, website):
if "details" in self._fail:
raise RuntimeError("details boom")
return CompanyDetails(description="A company.", leadership_team=self._leadership)
async def get_funding(self, name, website):
if "funding" in self._fail:
raise RuntimeError("funding boom")
return CompanyFunding(total_raised="$1M")
async def get_updates(self, name, website):
if "updates" in self._fail:
raise RuntimeError("updates boom")
return []
async def get_competitors(self, name, website):
if "competitors" in self._fail:
raise RuntimeError("competitors boom")
return []
async def get_products(self, name, website):
if "products" in self._fail:
raise RuntimeError("products boom")
return []
async def get_customers(self, name, website):
if "customers" in self._fail:
raise RuntimeError("customers boom")
return []
async def get_work_email(self, person_name, company_website):
if "work_email" in self._fail:
raise RuntimeError("email boom")
return f"{person_name.split()[0].lower()}@example.com"
async def get_person_profile(self, person_name, company_website):
if "person_profile" in self._fail:
raise RuntimeError("profile boom")
return f"https://example.com/{person_name}", "A bio."
async def _make_company(db_session, *, website: str | None = "https://acme.example.com") -> Company:
company = Company(
id=uuid.uuid4(),
user_id=uuid.uuid4(),
name="Acme Corp",
slug=f"acme-{uuid.uuid4().hex[:6]}",
official_website=website,
)
db_session.add(company)
await db_session.commit()
return company
@pytest.mark.asyncio
async def test_all_sections_succeeding_yields_complete_status(db_session, settings):
company = await _make_company(db_session)
provider = _FakeProvider()
enrichment = await enrich_company(db_session, settings, provider, company)
assert enrichment.status == EnrichmentStatus.COMPLETE
assert enrichment.errors == {}
assert enrichment.data["funding"]["total_raised"] == "$1M"
assert enrichment.credits_spent is not None and enrichment.credits_spent > 0
@pytest.mark.asyncio
async def test_one_failed_section_yields_partial_without_losing_the_rest(db_session, settings):
company = await _make_company(db_session)
provider = _FakeProvider(fail={"funding"})
enrichment = await enrich_company(db_session, settings, provider, company)
assert enrichment.status == EnrichmentStatus.PARTIAL
assert "funding" in enrichment.errors
assert "funding" not in enrichment.data
assert enrichment.data["description"] == "A company." # the other sections still ran
@pytest.mark.asyncio
async def test_every_section_failing_yields_failed_status(db_session, settings):
company = await _make_company(db_session)
provider = _FakeProvider(
fail={"details", "funding", "updates", "competitors", "products", "customers"}
)
enrichment = await enrich_company(db_session, settings, provider, company)
assert enrichment.status == EnrichmentStatus.FAILED
assert enrichment.data.get("leadership_team") == []
@pytest.mark.asyncio
async def test_leadership_lookups_are_capped(db_session, settings):
company = await _make_company(db_session)
leadership = [LeadershipMember(name=f"Person {i}") for i in range(8)]
provider = _FakeProvider(leadership=leadership)
capped_settings = settings.model_copy(update={"ninjapear_max_leadership_lookups": 3})
enrichment = await enrich_company(db_session, capped_settings, provider, company)
team = enrichment.data["leadership_team"]
assert len(team) == 8 # every discovered leader is kept...
with_email = [m for m in team if m.get("work_email")]
assert len(with_email) == 3 # ...but only the first 3 get person-level lookups
@pytest.mark.asyncio
async def test_no_website_fails_immediately_without_calling_the_provider(db_session, settings):
"""NinjaPear identifies a company by website only - every call would
fail identically, so this must short-circuit to FAILED before spending
any credits, rather than attempting (and paying for) doomed calls."""
company = await _make_company(db_session, website=None)
class _ExplodingProvider:
provider_name = "exploding"
async def get_company_details(self, *a, **k):
raise AssertionError("must not be called without a website")
enrichment = await enrich_company(db_session, settings, _ExplodingProvider(), company)
assert enrichment.status == EnrichmentStatus.FAILED
assert enrichment.credits_spent == 0
assert "details" in enrichment.errors
@pytest.mark.asyncio
async def test_upsert_updates_the_same_row_on_a_second_call(db_session, settings):
company = await _make_company(db_session)
first = await enrich_company(db_session, settings, _FakeProvider(), company)
second = await enrich_company(db_session, settings, _FakeProvider(fail={"funding"}), company)
assert first.id == second.id
assert second.status == EnrichmentStatus.PARTIAL
stored = await CompanyEnrichmentRepository(db_session).get_for_company(company.id)
assert stored.id == first.id
assert stored.status == EnrichmentStatus.PARTIAL
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from app.collectors.extraction import (
canonicalize_url,
compute_content_hash,
extract_readable_text,
extract_title,
normalize_whitespace,
)
def test_normalize_whitespace_collapses_blank_lines_and_trims():
raw = " Title \n\n\n\nBody line one \n \nBody line two "
normalized = normalize_whitespace(raw)
assert normalized == "Title\n\nBody line one\n\nBody line two"
def test_compute_content_hash_is_stable_and_sensitive_to_change():
a = compute_content_hash("hello world")
b = compute_content_hash("hello world")
c = compute_content_hash("hello world!")
assert a == b
assert a != c
def test_canonicalize_url_strips_tracking_params_and_trailing_slash():
url = "HTTPS://Example.com/About/?utm_source=x&ref=y"
assert canonicalize_url(url) == "https://example.com/About?ref=y"
def test_canonicalize_url_normalizes_root_path():
assert canonicalize_url("https://example.com") == "https://example.com/"
def test_extract_readable_text_prefers_trafilatura_for_article_html():
html = """
<html><body>
<nav>Home | About | Contact</nav>
<article>
<h1>Acme launches new product</h1>
<p>Acme Corp announced a new electric vehicle platform today, expanding its lineup.</p>
<p>The company said manufacturing will begin next quarter at its main facility.</p>
</article>
<footer>Copyright 2026</footer>
</body></html>
"""
text, method = extract_readable_text(html, "https://example.com/news/1")
assert "Acme launches new product" in text
assert "Copyright 2026" not in text
assert method in ("trafilatura", "beautifulsoup_fallback")
def test_extract_readable_text_falls_back_when_trafilatura_finds_nothing():
html = "<html><body></body></html>"
text, method = extract_readable_text(html, "https://example.com/thin")
assert method == "beautifulsoup_fallback"
assert text == ""
def test_extract_title_prefers_title_tag():
html = "<html><head><title>Acme — About</title></head><body><h1>About</h1></body></html>"
assert extract_title(html) == "Acme — About"
def test_extract_title_falls_back_to_h1():
html = "<html><head></head><body><h1>Fallback Heading</h1></body></html>"
assert extract_title(html) == "Fallback Heading"
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from app.change_detection.extractors import extract_prices, mentions_leadership_title
def test_extract_prices_finds_dollar_amounts():
text = "The Pro plan is $49/month and the Enterprise plan is $199.99/month."
prices = extract_prices(text)
assert "$49/month" in prices
assert "$199.99/month" in prices
def test_extract_prices_empty_when_no_prices():
assert extract_prices("No pricing information on this page.") == set()
def test_mentions_leadership_title_detects_ceo():
assert mentions_leadership_title("Jane Smith has been appointed as the new CEO.") is True
def test_mentions_leadership_title_false_when_absent():
assert mentions_leadership_title("We shipped a new feature this week.") is False
+25
View File
@@ -0,0 +1,25 @@
"""Basic liveness/readiness smoke tests."""
from __future__ import annotations
def test_root_health(client):
resp = client.get("/health")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
def test_v1_health(client):
resp = client.get("/api/v1/health")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert "app_name" in body
def test_system_status_reports_mock_providers(client):
resp = client.get("/api/v1/system/status")
assert resp.status_code == 200
body = resp.json()
assert body["llm_provider"] == "mock"
assert body["search_provider"] == "mock"
@@ -0,0 +1,245 @@
"""ip_throttle_service: the escalation engine backing resend-verification,
resend-password-reset, and failed-login throttling. Every stage is walked
by monkeypatching `_now()` forward - no real waiting, no manual
brute-forcing, per the explicit "test it smarter" requirement this feature
was built under."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from app.db.base import ensure_aware_utc
from app.models.enums import ThrottleAction
from app.repositories.ip_throttle_repository import IpThrottleRepository
from app.services import ip_throttle_service
from app.services.ip_throttle_service import (
LOGIN_BACKOFF_SECONDS,
RESEND_BACKOFF_SECONDS,
TIMEOUT_LADDER_SECONDS,
peek_throttle,
record_attempt,
reset_on_success,
)
def _unique_ip() -> str:
return f"10.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}"
class _Clock:
"""A monkeypatchable fake clock - advances only when told to, so tests
can jump straight past a 5-hour timeout without real time passing."""
def __init__(self) -> None:
self.now = datetime(2026, 1, 1, tzinfo=UTC)
def __call__(self) -> datetime:
return self.now
def advance(self, seconds: float) -> None:
self.now += timedelta(seconds=seconds)
@pytest.fixture()
def clock(monkeypatch):
fake_clock = _Clock()
monkeypatch.setattr(ip_throttle_service, "_now", fake_clock)
return fake_clock
async def _exhaust_stages_into_timeout(db_session, clock, ip: str, action: ThrottleAction) -> None:
"""Drives one full stage array to exhaustion (advancing the clock past
each required wait, exactly as a real repeated-offender caller would),
landing the state in a fresh timeout. Always starts from a peek (so a
prior *completed* timeout cycle is lazily reset first, mirroring how
every real call site checks peek_throttle before record_attempt)."""
for _ in RESEND_BACKOFF_SECONDS:
peeked = await peek_throttle(db_session, ip, action)
assert peeked.allowed is True
await record_attempt(db_session, ip, action, RESEND_BACKOFF_SECONDS)
state = await IpThrottleRepository(db_session).get_state(ip, action)
if state.next_allowed_at is not None:
next_allowed_at = ensure_aware_utc(state.next_allowed_at)
clock.advance((next_allowed_at - clock.now).total_seconds())
# One more attempt now that every stage is consumed - this is the one
# that exhausts the array and enters a timeout.
await record_attempt(db_session, ip, action, RESEND_BACKOFF_SECONDS)
async def test_first_attempt_is_always_allowed(db_session, clock):
ip = _unique_ip()
result = await peek_throttle(db_session, ip, ThrottleAction.RESEND_VERIFICATION)
assert result.allowed is True
assert result.banned is False
async def test_resend_backoff_stages_match_spec_exactly(db_session, clock):
ip = _unique_ip()
action = ThrottleAction.RESEND_VERIFICATION
for expected_wait in RESEND_BACKOFF_SECONDS:
result = await peek_throttle(db_session, ip, action)
assert result.allowed is True
await record_attempt(db_session, ip, action, RESEND_BACKOFF_SECONDS)
blocked = await peek_throttle(db_session, ip, action)
assert blocked.allowed is False
assert blocked.retry_after_seconds == expected_wait
clock.advance(expected_wait)
async def test_resend_exhausting_stages_enters_first_timeout(db_session, clock):
ip = _unique_ip()
action = ThrottleAction.RESEND_RESET
await _exhaust_stages_into_timeout(db_session, clock, ip, action)
blocked = await peek_throttle(db_session, ip, action)
assert blocked.allowed is False
assert blocked.retry_after_seconds == TIMEOUT_LADDER_SECONDS[0]
async def test_timeout_expires_resets_attempts_but_keeps_offense_memory(db_session, clock):
ip = _unique_ip()
action = ThrottleAction.RESEND_RESET
await _exhaust_stages_into_timeout(db_session, clock, ip, action)
repo = IpThrottleRepository(db_session)
state = await repo.get_state(ip, action)
assert state.offense_count == 1
clock.advance(TIMEOUT_LADDER_SECONDS[0])
result = await peek_throttle(db_session, ip, action)
assert result.allowed is True
state = await repo.get_state(ip, action)
assert state.attempt_count == 0
assert state.offense_count == 1 # memory kept, exactly as specified
async def test_repeat_offense_uses_next_longer_timeout(db_session, clock):
ip = _unique_ip()
action = ThrottleAction.RESEND_RESET
await _exhaust_stages_into_timeout(db_session, clock, ip, action) # offense #1 -> 30min
clock.advance(TIMEOUT_LADDER_SECONDS[0])
await _exhaust_stages_into_timeout(db_session, clock, ip, action) # offense #2 -> 1h
blocked = await peek_throttle(db_session, ip, action)
assert blocked.retry_after_seconds == TIMEOUT_LADDER_SECONDS[1]
async def test_escalation_past_the_ladder_results_in_permanent_ban(db_session, clock):
ip = _unique_ip()
action = ThrottleAction.RESEND_RESET
for i, timeout_seconds in enumerate(TIMEOUT_LADDER_SECONDS):
await _exhaust_stages_into_timeout(db_session, clock, ip, action)
clock.advance(timeout_seconds)
result = await peek_throttle(db_session, ip, action)
assert result.banned is False, f"should not be banned yet after offense {i + 1}"
# One more full cycle exhausts past the ladder entirely -> permanent ban.
await _exhaust_stages_into_timeout(db_session, clock, ip, action)
result = await peek_throttle(db_session, ip, action)
assert result.banned is True
assert result.allowed is False
async def test_login_five_instant_attempts_then_escalating_delays(db_session, clock):
ip = _unique_ip()
action = ThrottleAction.FAILED_LOGIN
for _ in range(5):
result = await peek_throttle(db_session, ip, action)
assert result.allowed is True
await record_attempt(db_session, ip, action, LOGIN_BACKOFF_SECONDS)
# All 5 were genuinely free - no wait was ever imposed before any of them.
blocked = await peek_throttle(db_session, ip, action)
assert blocked.allowed is False
assert blocked.retry_after_seconds == 5 # first real delay stage, gating attempt 6
async def test_login_all_delay_stages_match_spec_in_order(db_session, clock):
ip = _unique_ip()
action = ThrottleAction.FAILED_LOGIN
for _ in range(5):
await record_attempt(db_session, ip, action, LOGIN_BACKOFF_SECONDS)
expected_delays = [5, 15, 30, 60, 120, 300, 900]
for expected_wait in expected_delays:
blocked = await peek_throttle(db_session, ip, action)
assert blocked.retry_after_seconds == expected_wait
clock.advance(expected_wait)
await record_attempt(db_session, ip, action, LOGIN_BACKOFF_SECONDS)
# That was the 12th recorded attempt (5 free + 7 delayed) - stages are
# now exhausted, so the IP itself enters its first timeout.
blocked = await peek_throttle(db_session, ip, action)
assert blocked.retry_after_seconds == TIMEOUT_LADDER_SECONDS[0]
async def test_ban_blocks_every_action_type_for_that_ip(db_session, clock):
ip = _unique_ip()
action = ThrottleAction.RESEND_RESET
for timeout_seconds in TIMEOUT_LADDER_SECONDS:
await _exhaust_stages_into_timeout(db_session, clock, ip, action)
clock.advance(timeout_seconds)
await _exhaust_stages_into_timeout(db_session, clock, ip, action) # permanent ban
# A totally different action from the same IP is also blocked - bans are
# global per-IP, not scoped to the action that triggered them.
login_result = await peek_throttle(db_session, ip, ThrottleAction.FAILED_LOGIN)
assert login_result.banned is True
async def test_reset_on_success_clears_stage_but_not_offense_count(db_session, clock):
ip = _unique_ip()
action = ThrottleAction.FAILED_LOGIN
for _ in range(6):
await record_attempt(db_session, ip, action, LOGIN_BACKOFF_SECONDS)
repo = IpThrottleRepository(db_session)
state = await repo.get_state(ip, action)
assert state.attempt_count == 6
await reset_on_success(db_session, ip, action)
state = await repo.get_state(ip, action)
assert state.attempt_count == 0
assert state.next_allowed_at is None
result = await peek_throttle(db_session, ip, action)
assert result.allowed is True
async def test_admin_unban_gives_a_clean_slate(db_session, clock):
ip = _unique_ip()
action = ThrottleAction.RESEND_RESET
for timeout_seconds in TIMEOUT_LADDER_SECONDS:
await _exhaust_stages_into_timeout(db_session, clock, ip, action)
clock.advance(timeout_seconds)
await _exhaust_stages_into_timeout(db_session, clock, ip, action) # permanent ban
result = await peek_throttle(db_session, ip, action)
assert result.banned is True
repo = IpThrottleRepository(db_session)
cleared = await repo.clear_ban_and_state(ip)
assert cleared is True
result = await peek_throttle(db_session, ip, action)
assert result.allowed is True
assert result.banned is False
state = await repo.get_state(ip, action)
assert state.offense_count == 0 # a real pardon, not just lifting the ban
+179
View File
@@ -0,0 +1,179 @@
"""Anthropic/Ollama providers, with the SDK/HTTP layer mocked - these never
run against a real paid API in the test suite. Verifies the structured-
output + repair-loop wiring actually works, not just that it imports."""
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
import pytest
import respx
from pydantic import BaseModel
from app.analysis.llm.base import LLMResponseError
from app.core.config import Settings
class _Toy(BaseModel):
answer: str
def _settings(**overrides) -> Settings:
defaults = {
"llm_provider": "anthropic",
"anthropic_api_key": "sk-test",
"anthropic_model": "claude-test",
"llm_max_retries": 1,
"llm_max_tokens_per_request": 100,
}
defaults.update(overrides)
return Settings(**defaults)
@pytest.mark.asyncio
async def test_anthropic_provider_parses_tool_use_response():
from app.analysis.llm.anthropic_provider import AnthropicLLMProvider
provider = AnthropicLLMProvider(_settings())
fake_response = SimpleNamespace(
content=[SimpleNamespace(type="tool_use", input={"answer": "42"})]
)
with patch.object(provider._client.messages, "create", AsyncMock(return_value=fake_response)):
result = await provider.generate_structured("system", "user", _Toy)
assert result.answer == "42"
@pytest.mark.asyncio
async def test_anthropic_provider_retries_then_raises_on_missing_tool_use():
from app.analysis.llm.anthropic_provider import AnthropicLLMProvider
provider = AnthropicLLMProvider(_settings(llm_max_retries=1))
fake_response = SimpleNamespace(content=[SimpleNamespace(type="text", text="oops")])
with patch.object(
provider._client.messages, "create", AsyncMock(return_value=fake_response)
) as mock_create:
with pytest.raises(LLMResponseError):
await provider.generate_structured("system", "user", _Toy)
assert mock_create.call_count == 2 # initial attempt + 1 retry
@pytest.mark.asyncio
async def test_anthropic_provider_generate_text_joins_text_blocks():
from app.analysis.llm.anthropic_provider import AnthropicLLMProvider
provider = AnthropicLLMProvider(_settings())
fake_response = SimpleNamespace(
content=[
SimpleNamespace(type="text", text="Hello"),
SimpleNamespace(type="text", text="world"),
]
)
with patch.object(provider._client.messages, "create", AsyncMock(return_value=fake_response)):
text = await provider.generate_text("system", "user")
assert text == "Hello\nworld"
@pytest.mark.asyncio
async def test_ollama_provider_parses_json_response():
from app.analysis.llm.ollama_provider import OllamaLLMProvider
settings = Settings(
llm_provider="ollama",
ollama_base_url="http://ollama.local:11434",
ollama_model="llama-test",
llm_max_retries=1,
)
provider = OllamaLLMProvider(settings)
with respx.mock:
respx.post("http://ollama.local:11434/api/chat").mock(
return_value=httpx.Response(
200, json={"message": {"content": json.dumps({"answer": "42"})}}
)
)
result = await provider.generate_structured("system", "user", _Toy)
assert result.answer == "42"
@pytest.mark.asyncio
async def test_ollama_provider_retries_then_raises_on_invalid_json():
from app.analysis.llm.ollama_provider import OllamaLLMProvider
settings = Settings(
llm_provider="ollama",
ollama_base_url="http://ollama.local:11434",
ollama_model="llama-test",
llm_max_retries=1,
)
provider = OllamaLLMProvider(settings)
with respx.mock:
route = respx.post("http://ollama.local:11434/api/chat").mock(
return_value=httpx.Response(200, json={"message": {"content": "not json"}})
)
with pytest.raises(LLMResponseError):
await provider.generate_structured("system", "user", _Toy)
assert route.call_count == 2
def _gemini_settings(**overrides) -> Settings:
defaults = {
"llm_provider": "gemini",
"gemini_api_key": "test-key",
"gemini_model": "gemini-test",
"llm_max_retries": 1,
"llm_max_tokens_per_request": 100,
}
defaults.update(overrides)
return Settings(**defaults)
@pytest.mark.asyncio
async def test_gemini_provider_parses_structured_response():
from app.analysis.llm.gemini_provider import GeminiLLMProvider
provider = GeminiLLMProvider(_gemini_settings())
fake_response = SimpleNamespace(parsed=_Toy(answer="42"))
with patch.object(
provider._client.aio.models, "generate_content", AsyncMock(return_value=fake_response)
):
result = await provider.generate_structured("system", "user", _Toy)
assert result.answer == "42"
@pytest.mark.asyncio
async def test_gemini_provider_retries_then_raises_when_unparsed():
from app.analysis.llm.gemini_provider import GeminiLLMProvider
provider = GeminiLLMProvider(_gemini_settings(llm_max_retries=1))
fake_response = SimpleNamespace(parsed=None)
with patch.object(
provider._client.aio.models, "generate_content", AsyncMock(return_value=fake_response)
) as mock_generate:
with pytest.raises(LLMResponseError):
await provider.generate_structured("system", "user", _Toy)
assert mock_generate.call_count == 2 # initial attempt + 1 retry
@pytest.mark.asyncio
async def test_gemini_provider_generate_text_returns_response_text():
from app.analysis.llm.gemini_provider import GeminiLLMProvider
provider = GeminiLLMProvider(_gemini_settings())
fake_response = SimpleNamespace(text="Hello world")
with patch.object(
provider._client.aio.models, "generate_content", AsyncMock(return_value=fake_response)
):
text = await provider.generate_text("system", "user")
assert text == "Hello world"
@@ -0,0 +1,160 @@
"""MockLLMProvider: every analysis task's response schema must come back
valid and genuinely reflect the evidence passed in - never an empty
placeholder unrelated to the input."""
from __future__ import annotations
import pytest
from app.analysis.llm.mock import MockLLMProvider
from app.prompts.alert_summarization import AlertSummary
from app.prompts.base import build_user_prompt
from app.prompts.change_significance import ChangeSignificanceAssessment
from app.prompts.extraction import ExtractionResult
from app.prompts.relevance import RelevanceAssessment
from app.prompts.report_generation import ReportContent
from app.prompts.synthesis import SynthesisResult
provider = MockLLMProvider()
@pytest.mark.asyncio
async def test_relevance_matches_focus_keyword():
prompt = build_user_prompt(
"assess",
{
"monitoring_focus": "electric vehicle manufacturing expansion",
"document_text": "The company announced a new manufacturing facility for electric vehicles.",
},
)
result = await provider.generate_structured("system", prompt, RelevanceAssessment)
assert result.is_relevant is True
assert result.matches_focus is True
@pytest.mark.asyncio
async def test_extraction_pulls_first_sentence_as_signal():
prompt = build_user_prompt(
"extract", {"document_text": "Acme Corp opened a new facility. It will employ 200 people."}
)
result = await provider.generate_structured("system", prompt, ExtractionResult)
assert len(result.signals) == 1
assert "Acme Corp opened a new facility" in result.signals[0].description
@pytest.mark.asyncio
async def test_synthesis_requires_multiple_signals():
prompt_one = build_user_prompt("synthesize", {"signals": [{"description": "a"}]})
result_one = await provider.generate_structured("system", prompt_one, SynthesisResult)
assert result_one.conclusions == []
prompt_two = build_user_prompt(
"synthesize", {"signals": [{"description": "a"}, {"description": "b"}]}
)
result_two = await provider.generate_structured("system", prompt_two, SynthesisResult)
assert len(result_two.conclusions) == 1
assert result_two.conclusions[0].source_count == 2
@pytest.mark.asyncio
async def test_report_reflects_evidence_counts():
prompt = build_user_prompt(
"report",
{
"company_profile": {"name": "Acme Corp"},
"source_documents": [
{
"id": "d1",
"title": "Job posting",
"url": "https://x.com/1",
"source_type": "job_posting",
"retrieved_date": "2026-01-01",
}
],
"detected_changes": [
{
"id": "c1",
"summary": "New job posting detected",
"change_type": "new_document",
"severity": "medium",
"confidence_score": 0.6,
"created_at": "2026-01-01",
}
],
"sources_that_failed_to_collect": ["Broken Source"],
},
)
result = await provider.generate_structured("system", prompt, ReportContent)
assert "Acme Corp" in result.executive_summary
assert "1" in result.executive_summary # document/change counts mentioned
assert len(result.recent_developments) == 1
assert len(result.hiring_signals) == 1
assert any("Broken Source" in u for u in result.unknowns_and_missing_data)
@pytest.mark.asyncio
async def test_report_grounds_overview_in_discovered_profile_even_with_no_documents():
"""The bug this covers: a report generated before any monitoring run had
collected evidence used to come back near-empty even though the company's
discovered profile (from onboarding) had real data. That profile data
must now ground company_overview/market_positioning."""
prompt = build_user_prompt(
"report",
{
"company_profile": {
"name": "Stripe",
"description": "Stripe builds economic infrastructure for the internet.",
"industry": "Financial infrastructure",
"headquarters": "South San Francisco, California",
"competitors": ["PayPal"],
"aliases": [],
},
"source_documents": [],
"detected_changes": [],
"sources_that_failed_to_collect": [],
},
)
result = await provider.generate_structured("system", prompt, ReportContent)
assert "economic infrastructure" in result.company_overview
assert "South San Francisco" in result.company_overview
assert "PayPal" in result.market_positioning
@pytest.mark.asyncio
async def test_change_significance_reflects_deterministic_severity():
prompt = build_user_prompt(
"assess",
{
"change_type": "leadership_change",
"deterministic_severity": "high",
"deterministic_confidence": 0.8,
},
)
result = await provider.generate_structured("system", prompt, ChangeSignificanceAssessment)
assert result.is_meaningful is True
assert result.should_notify is True
assert "high" in result.why_it_matters
@pytest.mark.asyncio
async def test_alert_summary_title_under_100_chars():
prompt = build_user_prompt(
"summarize",
{
"company_name": "Acme Corp",
"change_type": "price_change",
"severity": "medium",
"confidence": 0.6,
},
)
result = await provider.generate_structured("system", prompt, AlertSummary)
assert len(result.title) <= 100
assert "Acme Corp" in result.title
@pytest.mark.asyncio
async def test_generate_text_does_not_crash():
prompt = build_user_prompt("do something", {"a": 1})
text = await provider.generate_text("system", prompt)
assert isinstance(text, str)
assert len(text) > 0
@@ -0,0 +1,220 @@
"""Monitoring run endpoints, exercised via HTTP with CELERY_TASK_ALWAYS_EAGER
so `.delay()` runs the task synchronously in-process (see app/tasks/base.py
for why that needs a threaded asyncio bridge to work from an async route
handler). No live network: every collector's discovery/collection call is
respx-mocked."""
from __future__ import annotations
import json
import uuid
from unittest.mock import patch
import httpx
import pytest
import respx
def _register_and_login(client) -> dict[str, str]:
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
def _create_company(client, headers, **overrides):
payload = {
"name": f"Run Test Co {uuid.uuid4().hex[:6]}",
"frequency_type": "weekly",
**overrides,
}
return client.post("/api/v1/companies", json=payload, headers=headers).json()
def _mock_empty_discovery():
"""Every discoverable collector type returns nothing (or a harmless
stub), so a first run doesn't need per-source mocks for whatever
discovery happens to find. RSS (Google News) and GOV_CONTRACT
(USASpending) are always discovered unconditionally - see
discovery_service.py's _PREVIEWABLE_TYPES - so their collect() calls
need mocking here too, unlike GitHub/SEC which discover.() itself."""
respx.get("https://api.github.com/search/users").mock(
return_value=httpx.Response(200, text=json.dumps({"items": []}))
)
respx.get("https://www.sec.gov/cgi-bin/browse-edgar").mock(
return_value=httpx.Response(
200, text='<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>'
)
)
respx.get(url__regex=r"https://news\.google\.com/rss/search.*").mock(
return_value=httpx.Response(
200,
text=(
'<?xml version="1.0"?><rss version="2.0"><channel><title>Google News</title>'
"<item><title>Test</title><link>https://example.com/news</link>"
"<description>Test news item</description></item></channel></rss>"
),
)
)
respx.post("https://api.usaspending.gov/api/v2/search/spending_by_award/").mock(
return_value=httpx.Response(200, json={"results": []})
)
def test_run_now_executes_and_reports_final_status(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
with respx.mock:
_mock_empty_discovery()
resp = client.post(f"/api/v1/companies/{company['id']}/run", headers=headers)
assert resp.status_code == 202
run = resp.json()
assert run["trigger_type"] == "manual"
# Eager mode means the task already finished by the time .delay() returns.
detail = client.get(f"/api/v1/runs/{run['id']}", headers=headers).json()
assert detail["status"] == "successful"
assert detail["completed_at"] is not None
async def test_run_now_is_idempotent_for_an_active_run(db_session, settings):
"""A run-now call while one is already queued/running returns that same
run instead of enqueuing a duplicate. Exercised at the service layer
directly: under CELERY_TASK_ALWAYS_EAGER a `.delay()` call runs the task
to completion before returning, so an HTTP-level test can never observe
an in-flight run to dedupe against."""
from app.models.company import Company
from app.models.enums import MonitoringRunTrigger
from app.models.monitor_configuration import MonitorConfiguration
from app.repositories.monitoring_run_repository import MonitoringRunRepository
from app.services import monitoring_service
user_id = uuid.uuid4()
company = Company(
id=uuid.uuid4(),
user_id=user_id,
name="Idempotency Co",
slug=f"idempotency-co-{uuid.uuid4().hex[:6]}",
)
db_session.add(company)
db_session.add(MonitorConfiguration(company_id=company.id))
await db_session.commit()
run_repo = MonitoringRunRepository(db_session)
existing = await run_repo.create(
company_id=company.id, trigger_type=MonitoringRunTrigger.SCHEDULED
)
await db_session.commit()
with patch("app.tasks.collection.run_monitoring.delay") as mock_delay:
result = await monitoring_service.enqueue_run_now(db_session, settings, user_id, company.id)
assert result.id == existing.id
mock_delay.assert_not_called()
async def test_run_now_enforces_daily_manual_run_cap(db_session, settings):
from app.core.errors import RateLimitedError
from app.models.company import Company
from app.models.enums import MonitoringRunStatus, MonitoringRunTrigger
from app.models.monitor_configuration import MonitorConfiguration
from app.repositories.monitoring_run_repository import MonitoringRunRepository
from app.services import monitoring_service
user_id = uuid.uuid4()
company = Company(
id=uuid.uuid4(),
user_id=user_id,
name="Rate Limited Co",
slug=f"rate-limited-co-{uuid.uuid4().hex[:6]}",
)
db_session.add(company)
db_session.add(MonitorConfiguration(company_id=company.id))
await db_session.commit()
run_repo = MonitoringRunRepository(db_session)
for _ in range(2):
run = await run_repo.create(company_id=company.id, trigger_type=MonitoringRunTrigger.MANUAL)
await run_repo.mark_finished(run, status=MonitoringRunStatus.SUCCESSFUL, error_summary=None)
await db_session.commit()
capped_settings = settings.model_copy(update={"max_manual_runs_per_day": 2})
with pytest.raises(RateLimitedError):
await monitoring_service.enqueue_run_now(db_session, capped_settings, user_id, company.id)
def test_run_history_and_single_run_scoped_to_owner(client):
owner_headers = _register_and_login(client)
other_headers = _register_and_login(client)
company = _create_company(client, owner_headers)
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
with respx.mock:
_mock_empty_discovery()
run = client.post(
f"/api/v1/companies/{company['id']}/run", headers=owner_headers
).json()
assert (
client.get(f"/api/v1/companies/{company['id']}/runs", headers=other_headers).status_code
== 404
)
assert client.get(f"/api/v1/runs/{run['id']}", headers=other_headers).status_code == 404
assert client.get(f"/api/v1/runs/{run['id']}", headers=owner_headers).status_code == 200
def test_successful_first_run_generates_a_baseline_report(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
client.post(
f"/api/v1/companies/{company['id']}/sources",
json={
"source_type": "custom_url",
"name": "Pricing",
"base_url": "https://example.com/pricing",
},
headers=headers,
)
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
with respx.mock:
_mock_empty_discovery()
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
respx.get("https://example.com/pricing").mock(
return_value=httpx.Response(
200,
html="<html><head><title>Pricing</title></head><body>"
"<article><h1>Pricing</h1><p>Plans start at $10/month.</p></article>"
"</body></html>",
)
)
client.post(f"/api/v1/companies/{company['id']}/run", headers=headers)
reports = client.get(f"/api/v1/companies/{company['id']}/reports", headers=headers).json()
assert len(reports) == 1
assert reports[0]["report_type"] == "baseline"
assert reports[0]["model_provider"] == "mock"
def test_manual_run_does_not_disrupt_next_scheduled_run(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
original_next_run = company["monitor_configuration"]["next_run"]
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
with respx.mock:
_mock_empty_discovery()
client.post(f"/api/v1/companies/{company['id']}/run", headers=headers)
refreshed = client.get(f"/api/v1/companies/{company['id']}", headers=headers).json()
assert refreshed["monitor_configuration"]["next_run"] == original_next_run
assert refreshed["monitor_configuration"]["last_run"] is not None
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from app.change_detection.noise_filters import strip_noise
def test_strips_dynamic_timestamps():
text = "Page content.\nLast modified: 2026-03-14 09:00:00\nMore content."
cleaned = strip_noise(text)
assert "2026-03-14" not in cleaned
assert "Page content." in cleaned
assert "More content." in cleaned
def test_strips_cookie_banner_boilerplate():
text = "We use cookies to improve your experience. Real content here."
cleaned = strip_noise(text)
assert "cookies" not in cleaned.lower()
assert "Real content here." in cleaned
def test_strips_copyright_year():
text = "Footer text. Copyright (c) 2026 Acme Corp. All rights reserved."
cleaned = strip_noise(text)
assert "2026" not in cleaned
def test_leaves_ordinary_prose_untouched():
text = "Acme Corp announced a new electric vehicle platform this week."
cleaned = strip_noise(text)
assert "Acme Corp announced a new electric vehicle platform this week." in cleaned
@@ -0,0 +1,331 @@
"""Notification destination CRUD, ownership isolation, and the
company-linking behavior this feature is built around: reusing an existing
destination by (type, value) instead of duplicating it, dedupe display,
and garbage-collecting a destination once no company references it."""
from __future__ import annotations
import uuid
def _register_and_login(client) -> dict[str, str]:
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
def _create_company(client, headers, name: str | None = None) -> dict:
return client.post(
"/api/v1/companies",
json={"name": name or f"Co {uuid.uuid4().hex[:6]}", "frequency_type": "weekly"},
headers=headers,
).json()
def test_create_email_destination(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
resp = client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "[email protected]",
"company_ids": [company["id"]],
},
headers=headers,
)
assert resp.status_code == 201
body = resp.json()
assert body["type"] == "email"
assert body["verified"] is False
assert body["minimum_severity"] == "medium"
assert [c["id"] for c in body["companies"]] == [company["id"]]
def test_create_destination_requires_at_least_one_company(client):
headers = _register_and_login(client)
resp = client.post(
"/api/v1/notification-destinations",
json={"type": "email", "destination_value": "[email protected]", "company_ids": []},
headers=headers,
)
assert resp.status_code == 422
def test_create_destination_rejects_a_company_id_the_user_doesnt_own(client):
owner_headers = _register_and_login(client)
other_headers = _register_and_login(client)
other_company = _create_company(client, other_headers)
resp = client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "[email protected]",
"company_ids": [other_company["id"]],
},
headers=owner_headers,
)
assert resp.status_code == 400
def test_create_email_destination_rejects_invalid_email(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
resp = client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "not-an-email",
"company_ids": [company["id"]],
},
headers=headers,
)
assert resp.status_code == 422
def test_create_sms_destination_rejects_invalid_phone(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
resp = client.post(
"/api/v1/notification-destinations",
json={"type": "sms", "destination_value": "not-a-phone", "company_ids": [company["id"]]},
headers=headers,
)
assert resp.status_code == 422
def test_create_sms_destination_accepts_e164(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
resp = client.post(
"/api/v1/notification-destinations",
json={
"type": "sms",
"destination_value": "+15551234567",
"company_ids": [company["id"]],
},
headers=headers,
)
assert resp.status_code == 201
def test_reusing_the_same_email_for_a_second_company_links_instead_of_duplicating(client):
"""The bug this feature exists to fix: the wizard used to create a brand
new NotificationDestination row per company even when the email was
already registered. Now it must reuse the same row and just add a link."""
headers = _register_and_login(client)
company_a = _create_company(client, headers, name="Company A")
company_b = _create_company(client, headers, name="Company B")
first = client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "[email protected]",
"company_ids": [company_a["id"]],
},
headers=headers,
).json()
second = client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "[email protected]", # different casing on purpose
"company_ids": [company_b["id"]],
},
headers=headers,
).json()
assert first["id"] == second["id"]
listing = client.get("/api/v1/notification-destinations", headers=headers).json()
assert len(listing) == 1
linked_ids = {c["id"] for c in listing[0]["companies"]}
assert linked_ids == {company_a["id"], company_b["id"]}
def test_update_destination_value_resets_verification(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
created = client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "[email protected]",
"company_ids": [company["id"]],
},
headers=headers,
).json()
resp = client.patch(
f"/api/v1/notification-destinations/{created['id']}",
json={"destination_value": "[email protected]"},
headers=headers,
)
assert resp.status_code == 200
assert resp.json()["verified"] is False
def test_destinations_scoped_to_owner(client):
owner_headers = _register_and_login(client)
other_headers = _register_and_login(client)
company = _create_company(client, owner_headers)
created = client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "[email protected]",
"company_ids": [company["id"]],
},
headers=owner_headers,
).json()
resp = client.patch(
f"/api/v1/notification-destinations/{created['id']}",
json={"enabled": False},
headers=other_headers,
)
assert resp.status_code == 404
def test_delete_destination(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
created = client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "[email protected]",
"company_ids": [company["id"]],
},
headers=headers,
).json()
resp = client.delete(f"/api/v1/notification-destinations/{created['id']}", headers=headers)
assert resp.status_code == 204
listing = client.get("/api/v1/notification-destinations", headers=headers).json()
assert all(d["id"] != created["id"] for d in listing)
def test_deleting_a_companys_only_destination_link_garbage_collects_it(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "[email protected]",
"company_ids": [company["id"]],
},
headers=headers,
)
resp = client.delete(f"/api/v1/companies/{company['id']}", headers=headers)
assert resp.status_code == 204
listing = client.get("/api/v1/notification-destinations", headers=headers).json()
assert listing == []
def test_deleting_one_of_two_linked_companies_keeps_the_destination(client):
headers = _register_and_login(client)
company_a = _create_company(client, headers, name="Keep")
company_b = _create_company(client, headers, name="Delete me")
client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "[email protected]",
"company_ids": [company_a["id"], company_b["id"]],
},
headers=headers,
)
resp = client.delete(f"/api/v1/companies/{company_b['id']}", headers=headers)
assert resp.status_code == 204
listing = client.get("/api/v1/notification-destinations", headers=headers).json()
assert len(listing) == 1
assert [c["id"] for c in listing[0]["companies"]] == [company_a["id"]]
def test_unlink_company_keeps_the_destination_when_other_links_remain(client):
headers = _register_and_login(client)
company_a = _create_company(client, headers, name="Keep")
company_b = _create_company(client, headers, name="Unlink me")
created = client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "[email protected]",
"company_ids": [company_a["id"], company_b["id"]],
},
headers=headers,
).json()
resp = client.delete(
f"/api/v1/notification-destinations/{created['id']}/companies/{company_b['id']}",
headers=headers,
)
assert resp.status_code == 204
listing = client.get("/api/v1/notification-destinations", headers=headers).json()
assert len(listing) == 1
assert [c["id"] for c in listing[0]["companies"]] == [company_a["id"]]
def test_unlinking_the_last_company_garbage_collects_the_destination(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
created = client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "[email protected]",
"company_ids": [company["id"]],
},
headers=headers,
).json()
resp = client.delete(
f"/api/v1/notification-destinations/{created['id']}/companies/{company['id']}",
headers=headers,
)
assert resp.status_code == 204
listing = client.get("/api/v1/notification-destinations", headers=headers).json()
assert listing == []
def test_unlink_company_is_scoped_to_the_destinations_owner(client):
owner_headers = _register_and_login(client)
other_headers = _register_and_login(client)
company = _create_company(client, owner_headers)
created = client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "[email protected]",
"company_ids": [company["id"]],
},
headers=owner_headers,
).json()
resp = client.delete(
f"/api/v1/notification-destinations/{created['id']}/companies/{company['id']}",
headers=other_headers,
)
assert resp.status_code == 404
# The destination and its link both survive the rejected attempt.
listing = client.get("/api/v1/notification-destinations", headers=owner_headers).json()
assert len(listing) == 1
assert [c["id"] for c in listing[0]["companies"]] == [company["id"]]
@@ -0,0 +1,121 @@
"""POST /notification-destinations/{id}/test - dedicated from
test_notification_destinations.py since it exercises actual send-path
dispatch (console/SMTP) rather than just CRUD."""
from __future__ import annotations
import uuid
def _register_and_login(client) -> dict[str, str]:
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
def _create_company(client, headers) -> dict:
return client.post(
"/api/v1/companies",
json={"name": f"Co {uuid.uuid4().hex[:6]}", "frequency_type": "weekly"},
headers=headers,
).json()
def test_test_endpoint_sends_to_email_destination(client, monkeypatch):
sent = {}
class FakeSmtp:
def __init__(self, host, port, timeout=10):
pass
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def starttls(self):
pass
def login(self, username, password):
pass
def sendmail(self, from_addr, to_addrs, message):
sent["to_addrs"] = to_addrs
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FakeSmtp)
headers = _register_and_login(client)
company = _create_company(client, headers)
destination = client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "[email protected]",
"company_ids": [company["id"]],
},
headers=headers,
).json()
resp = client.post(
f"/api/v1/notification-destinations/{destination['id']}/test", headers=headers
)
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert body["error"] is None
assert sent["to_addrs"] == ["[email protected]"]
def test_test_endpoint_reports_failure(client, monkeypatch):
class FailingSmtp:
def __init__(self, host, port, timeout=10):
raise ConnectionRefusedError("no mailpit running")
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FailingSmtp)
headers = _register_and_login(client)
company = _create_company(client, headers)
destination = client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "[email protected]",
"company_ids": [company["id"]],
},
headers=headers,
).json()
resp = client.post(
f"/api/v1/notification-destinations/{destination['id']}/test", headers=headers
)
assert resp.status_code == 200
body = resp.json()
assert body["success"] is False
assert "no mailpit running" in body["error"]
def test_test_endpoint_not_owned_returns_404(client):
owner_headers = _register_and_login(client)
other_headers = _register_and_login(client)
company = _create_company(client, owner_headers)
destination = client.post(
"/api/v1/notification-destinations",
json={
"type": "email",
"destination_value": "[email protected]",
"company_ids": [company["id"]],
},
headers=owner_headers,
).json()
resp = client.post(
f"/api/v1/notification-destinations/{destination['id']}/test", headers=other_headers
)
assert resp.status_code == 404
@@ -0,0 +1,256 @@
"""Notification provider unit tests: console (always succeeds), SMTP (stdlib
smtplib mocked, never touches a real socket), Twilio (respx-mocked REST
calls), the factory's type routing, and the message builder's per-channel
formatting."""
from __future__ import annotations
import uuid
import httpx
import pytest
import respx
from app.core.config import Settings
from app.models.alert import Alert
from app.models.company import Company
from app.models.enums import NotificationType, SeverityLevel
from app.notifications.base import NotificationMessage
from app.notifications.console import ConsoleProvider
from app.notifications.factory import get_notification_provider
from app.notifications.message_builder import build_alert_message
from app.notifications.smtp_email import SmtpEmailProvider
from app.notifications.telnyx_sms import TelnyxSmsProvider
from app.notifications.twilio_sms import TwilioSmsProvider
@pytest.mark.asyncio
async def test_console_provider_always_succeeds():
result = await ConsoleProvider().send(
NotificationMessage(destination_value="dev@local", subject="Test", body_text="hi")
)
assert result.success is True
@pytest.mark.asyncio
async def test_smtp_provider_sends_via_smtplib(monkeypatch):
sent = {}
class FakeSmtp:
def __init__(self, host, port, timeout=10):
sent["host"] = host
sent["port"] = port
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def starttls(self):
sent["starttls"] = True
def login(self, username, password):
sent["login"] = (username, password)
def sendmail(self, from_addr, to_addrs, message):
sent["from_addr"] = from_addr
sent["to_addrs"] = to_addrs
sent["message"] = message
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FakeSmtp)
settings = Settings(
smtp_host="mailpit", smtp_port=1025, smtp_from_email="[email protected]"
)
provider = SmtpEmailProvider(settings)
result = await provider.send(
NotificationMessage(
destination_value="[email protected]",
subject="Alert: Pricing change",
body_text="Plain text body",
body_html="<p>HTML body</p>",
)
)
assert result.success is True
assert sent["host"] == "mailpit"
assert sent["to_addrs"] == ["[email protected]"]
assert "starttls" not in sent # smtp_use_tls defaults False
@pytest.mark.asyncio
async def test_smtp_provider_reports_failure_without_raising(monkeypatch):
class FailingSmtp:
def __init__(self, host, port, timeout=10):
raise ConnectionRefusedError("no mailpit running")
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FailingSmtp)
provider = SmtpEmailProvider(Settings())
result = await provider.send(
NotificationMessage(destination_value="[email protected]", subject="s", body_text="b")
)
assert result.success is False
assert "no mailpit running" in result.error
@pytest.mark.asyncio
async def test_twilio_provider_not_configured_fails_cleanly():
settings = Settings(twilio_account_sid="", twilio_auth_token="", twilio_from_number="")
result = await TwilioSmsProvider(settings).send(
NotificationMessage(destination_value="+15551234567", subject="s", body_text="b")
)
assert result.success is False
assert "not configured" in result.error
@pytest.mark.asyncio
async def test_twilio_provider_sends_via_rest_api():
settings = Settings(
twilio_account_sid="ACxxxx", twilio_auth_token="secret", twilio_from_number="+15559990000"
)
with respx.mock:
respx.post("https://api.twilio.com/2010-04-01/Accounts/ACxxxx/Messages.json").mock(
return_value=httpx.Response(201, json={"sid": "SMxxxxx"})
)
result = await TwilioSmsProvider(settings).send(
NotificationMessage(
destination_value="+15551234567", subject="s", body_text="Alert text"
)
)
assert result.success is True
assert result.external_message_id == "SMxxxxx"
@pytest.mark.asyncio
async def test_twilio_provider_surfaces_api_error():
settings = Settings(
twilio_account_sid="ACxxxx", twilio_auth_token="secret", twilio_from_number="+15559990000"
)
with respx.mock:
respx.post("https://api.twilio.com/2010-04-01/Accounts/ACxxxx/Messages.json").mock(
return_value=httpx.Response(400, text="Invalid 'To' number")
)
result = await TwilioSmsProvider(settings).send(
NotificationMessage(destination_value="not-a-number", subject="s", body_text="b")
)
assert result.success is False
assert "400" in result.error
@pytest.mark.asyncio
async def test_telnyx_provider_not_configured_fails_cleanly():
settings = Settings(telnyx_api_key="", telnyx_from_number="")
result = await TelnyxSmsProvider(settings).send(
NotificationMessage(destination_value="+15551234567", subject="s", body_text="b")
)
assert result.success is False
assert "not configured" in result.error
@pytest.mark.asyncio
async def test_telnyx_provider_sends_via_rest_api():
settings = Settings(telnyx_api_key="KEYxxxx", telnyx_from_number="+15559990000")
with respx.mock:
respx.post("https://api.telnyx.com/v2/messages").mock(
return_value=httpx.Response(200, json={"data": {"id": "msg-abc123"}})
)
result = await TelnyxSmsProvider(settings).send(
NotificationMessage(
destination_value="+15551234567", subject="s", body_text="Alert text"
)
)
assert result.success is True
assert result.external_message_id == "msg-abc123"
@pytest.mark.asyncio
async def test_telnyx_provider_surfaces_api_error():
settings = Settings(telnyx_api_key="KEYxxxx", telnyx_from_number="+15559990000")
with respx.mock:
respx.post("https://api.telnyx.com/v2/messages").mock(
return_value=httpx.Response(
403,
json={
"errors": [
{
"code": "40300",
"title": "Forbidden",
"detail": "The from number is not assigned to a messaging profile.",
}
]
},
)
)
result = await TelnyxSmsProvider(settings).send(
NotificationMessage(destination_value="+15551234567", subject="s", body_text="b")
)
assert result.success is False
assert "403" in result.error
assert "messaging profile" in result.error
def test_factory_routes_by_notification_type():
settings = Settings()
assert get_notification_provider(NotificationType.EMAIL, settings).provider_name == "smtp"
assert get_notification_provider(NotificationType.SMS, settings).provider_name == "twilio_sms"
assert get_notification_provider(NotificationType.CONSOLE, settings).provider_name == "console"
def test_factory_routes_sms_by_sms_provider_setting():
twilio_settings = Settings(sms_provider="twilio")
assert (
get_notification_provider(NotificationType.SMS, twilio_settings).provider_name
== "twilio_sms"
)
telnyx_settings = Settings(sms_provider="telnyx")
assert (
get_notification_provider(NotificationType.SMS, telnyx_settings).provider_name
== "telnyx_sms"
)
def _sample_alert() -> Alert:
return Alert(
id=uuid.uuid4(),
company_id=uuid.uuid4(),
detected_change_id=uuid.uuid4(),
user_id=uuid.uuid4(),
title="New leadership hire announced",
summary="A new VP of Engineering was announced on the careers page.",
why_it_matters="Signals a scaling push in engineering.",
severity=SeverityLevel.HIGH,
confidence=0.82,
)
def test_message_builder_sms_is_short_and_truncated():
company = Company(id=uuid.uuid4(), user_id=uuid.uuid4(), name="Acme Mobility", slug="acme")
message = build_alert_message(
NotificationType.SMS, "+15551234567", company, _sample_alert(), Settings()
)
assert len(message.body_text) <= 480
assert "HIGH" in message.body_text
assert message.body_html is None
def test_message_builder_email_includes_context_and_links():
company = Company(id=uuid.uuid4(), user_id=uuid.uuid4(), name="Acme Mobility", slug="acme")
alert = _sample_alert()
message = build_alert_message(
NotificationType.EMAIL, "[email protected]", company, alert, Settings()
)
assert "Acme Mobility" in message.subject
assert alert.summary in message.body_text
assert alert.why_it_matters in message.body_text
assert "/alerts" in message.body_text
assert "/settings" in message.body_text
assert message.body_html is not None
assert alert.summary in message.body_html
+89
View File
@@ -0,0 +1,89 @@
"""The suite disables the rate limiter globally (see conftest.py) so the many
auth calls other tests make don't trip real limits. This test re-enables it
temporarily to verify the limiter itself actually works."""
from __future__ import annotations
import uuid
from fastapi import Request
from app.core.config import get_settings
from app.core.rate_limit import _client_ip_key, limiter
def _build_request(headers: dict[str, str], client_ip: str) -> Request:
scope = {
"type": "http",
"headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()],
"client": (client_ip, 12345),
}
return Request(scope)
def test_client_ip_key_uses_direct_peer_when_no_proxy_header_configured():
# Default test settings have trusted_proxy_ip_header="" - the header
# must be ignored even if a client sends it, or a spoofed header could
# forge any rate-limit identity with no proxy actually in front.
request = _build_request({"CF-Connecting-IP": "203.0.113.5"}, client_ip="10.0.0.9")
assert _client_ip_key(request) == "10.0.0.9"
def test_client_ip_key_honors_configured_proxy_header(monkeypatch):
# Once deployed behind Cloudflare, trusted_proxy_ip_header="CF-Connecting-IP"
# must make the limiter key off the real visitor, not Nginx's own address -
# this is the exact bug slowapi's default get_remote_address had.
settings = get_settings().model_copy(update={"trusted_proxy_ip_header": "CF-Connecting-IP"})
monkeypatch.setattr("app.core.rate_limit.get_settings", lambda: settings)
request = _build_request({"CF-Connecting-IP": "203.0.113.5"}, client_ip="10.0.0.9")
assert _client_ip_key(request) == "203.0.113.5"
def test_register_endpoint_enforces_rate_limit(client):
limiter.enabled = True
try:
responses = [
client.post(
"/api/v1/auth/register",
json={
"email": f"rl-{uuid.uuid4().hex[:10]}@example.com",
"password": "correct-horse-1",
"display_name": "Rate Limit Test",
},
)
for _ in range(6)
]
finally:
limiter.enabled = False
statuses = [r.status_code for r in responses]
assert 429 in statuses, f"Expected a 429 among {statuses} after 6 rapid registrations"
def test_create_company_enforces_rate_limit(client):
email = f"rl-company-{uuid.uuid4().hex[:10]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "RL Test"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
limiter.enabled = True
try:
responses = [
client.post(
"/api/v1/companies",
json={"name": f"RL Co {i}", "frequency_type": "weekly"},
headers=headers,
)
for i in range(22)
]
finally:
limiter.enabled = False
statuses = [r.status_code for r in responses]
assert 429 in statuses, f"Expected a 429 among {statuses} after 22 rapid company creations"
@@ -0,0 +1,69 @@
"""generate_report(): the company_enrichment evidence block reaches the
prompt exactly like company_profile does, and is an honest empty dict
when no enrichment data is available - never fabricated."""
from __future__ import annotations
import pytest
from app.prompts.base import extract_evidence_block
from app.prompts.report_generation import ReportContent, SwotAnalysis, generate_report
class _CapturingLLMProvider:
provider_name = "capturing"
def __init__(self) -> None:
self.last_user_prompt: str | None = None
async def generate_structured(self, system_prompt, user_prompt, response_model):
self.last_user_prompt = user_prompt
return ReportContent(
executive_summary="",
company_overview="",
market_positioning="",
customer_sentiment="",
competitor_comparison="",
swot=SwotAnalysis(),
methodology="",
limitations="",
)
async def generate_text(self, system_prompt, user_prompt) -> str:
return ""
async def _generate(llm: _CapturingLLMProvider, **kwargs) -> None:
await generate_report(
llm,
company_name="Acme Corp",
company_aliases=[],
competitors=[],
monitoring_focus=None,
industry=None,
documents=[],
detected_changes=[],
sources_failed=[],
**kwargs,
)
@pytest.mark.asyncio
async def test_enrichment_data_reaches_the_prompt_as_a_named_evidence_block():
llm = _CapturingLLMProvider()
await _generate(
llm, enrichment={"employee_count": "1001-5000", "funding": {"total_raised": "$1M"}}
)
evidence = extract_evidence_block(llm.last_user_prompt)
assert evidence["company_enrichment"]["employee_count"] == "1001-5000"
assert evidence["company_enrichment"]["funding"]["total_raised"] == "$1M"
@pytest.mark.asyncio
async def test_no_enrichment_data_is_an_honest_empty_block_not_fabricated():
llm = _CapturingLLMProvider()
await _generate(llm) # enrichment defaults to None
evidence = extract_evidence_block(llm.last_user_prompt)
assert evidence["company_enrichment"] == {}
+91
View File
@@ -0,0 +1,91 @@
"""Reports API: ownership isolation, manual generation, and the raw
markdown/json export endpoints."""
from __future__ import annotations
import uuid
def _register_and_login(client) -> dict[str, str]:
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
def _create_company(client, headers):
return client.post(
"/api/v1/companies",
json={"name": f"Report Co {uuid.uuid4().hex[:6]}", "frequency_type": "weekly"},
headers=headers,
).json()
def test_generate_report_creates_and_returns_report(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
resp = client.post(f"/api/v1/companies/{company['id']}/reports/generate", headers=headers)
assert resp.status_code == 201
body = resp.json()
assert body["report_type"] == "manual"
assert body["model_provider"] == "mock"
assert company["name"] in body["executive_summary"]
def test_list_reports_and_get_detail(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
created = client.post(
f"/api/v1/companies/{company['id']}/reports/generate", headers=headers
).json()
listing = client.get(f"/api/v1/companies/{company['id']}/reports", headers=headers).json()
assert any(r["id"] == created["id"] for r in listing)
detail = client.get(f"/api/v1/reports/{created['id']}", headers=headers)
assert detail.status_code == 200
assert detail.json()["structured_report"]["executive_summary"]
def test_report_markdown_and_json_export(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
created = client.post(
f"/api/v1/companies/{company['id']}/reports/generate", headers=headers
).json()
md_resp = client.get(f"/api/v1/reports/{created['id']}/markdown", headers=headers)
assert md_resp.status_code == 200
assert md_resp.headers["content-type"].startswith("text/markdown")
assert "# Competitive Intelligence Report" in md_resp.text
json_resp = client.get(f"/api/v1/reports/{created['id']}/json", headers=headers)
assert json_resp.status_code == 200
assert "executive_summary" in json_resp.json()
def test_reports_scoped_to_owner(client):
owner_headers = _register_and_login(client)
other_headers = _register_and_login(client)
company = _create_company(client, owner_headers)
created = client.post(
f"/api/v1/companies/{company['id']}/reports/generate", headers=owner_headers
).json()
assert (
client.get(f"/api/v1/companies/{company['id']}/reports", headers=other_headers).status_code
== 404
)
assert client.get(f"/api/v1/reports/{created['id']}", headers=other_headers).status_code == 404
assert (
client.post(
f"/api/v1/companies/{company['id']}/reports/generate", headers=other_headers
).status_code
== 404
)
+130
View File
@@ -0,0 +1,130 @@
"""Tests for the documented significance/confidence/severity formula in
app/change_detection/scoring.py (see ARCHITECTURE.md)."""
from __future__ import annotations
from app.change_detection.scoring import classify_severity, compute_confidence, compute_significance
from app.models.enums import ChangeType, SeverityLevel
def test_significance_scales_with_source_trust():
high_trust = compute_significance(
change_type=ChangeType.NEW_DOCUMENT, source_trust_score=1.0, independent_source_count=2
)
low_trust = compute_significance(
change_type=ChangeType.NEW_DOCUMENT, source_trust_score=0.3, independent_source_count=2
)
assert high_trust > low_trust
def test_significance_uncorroborated_signal_is_halved():
corroborated = compute_significance(
change_type=ChangeType.NEW_DOCUMENT, source_trust_score=1.0, independent_source_count=2
)
single_source = compute_significance(
change_type=ChangeType.NEW_DOCUMENT, source_trust_score=1.0, independent_source_count=1
)
assert single_source == round(corroborated / 2, 4)
def test_significance_focus_match_boosts_score():
matched = compute_significance(
change_type=ChangeType.NEW_DOCUMENT,
source_trust_score=1.0,
independent_source_count=2,
focus_match=True,
)
unmatched = compute_significance(
change_type=ChangeType.NEW_DOCUMENT,
source_trust_score=1.0,
independent_source_count=2,
focus_match=False,
)
assert matched > unmatched
def test_significance_repeat_change_is_dampened():
fresh = compute_significance(
change_type=ChangeType.NEW_DOCUMENT, source_trust_score=1.0, is_repeat=False
)
repeat = compute_significance(
change_type=ChangeType.NEW_DOCUMENT, source_trust_score=1.0, is_repeat=True
)
assert repeat == round(fresh / 2, 4)
def test_significance_content_modified_scales_with_diff_ratio():
small_edit = compute_significance(
change_type=ChangeType.CONTENT_MODIFIED,
source_trust_score=1.0,
independent_source_count=2,
diff_ratio=0.05,
)
big_rewrite = compute_significance(
change_type=ChangeType.CONTENT_MODIFIED,
source_trust_score=1.0,
independent_source_count=2,
diff_ratio=0.9,
)
assert big_rewrite > small_edit
def test_significance_never_exceeds_one():
value = compute_significance(
change_type=ChangeType.LEADERSHIP_CHANGE,
source_trust_score=1.0,
independent_source_count=10,
focus_match=True,
)
assert value <= 1.0
def test_confidence_increases_with_corroboration():
single = compute_confidence(
extraction_confidence=0.8, source_trust_score=0.8, independent_source_count=1
)
corroborated = compute_confidence(
extraction_confidence=0.8, source_trust_score=0.8, independent_source_count=2
)
assert corroborated > single
def test_confidence_bounded_between_zero_and_one():
assert (
0.0
<= compute_confidence(
extraction_confidence=0.0, source_trust_score=0.0, independent_source_count=1
)
<= 1.0
)
assert (
0.0
<= compute_confidence(
extraction_confidence=1.0, source_trust_score=1.0, independent_source_count=5
)
<= 1.0
)
def test_classify_severity_buckets_by_score():
assert classify_severity(significance=0.9, confidence=0.9) == SeverityLevel.CRITICAL
assert classify_severity(significance=0.6, confidence=0.9) == SeverityLevel.HIGH
assert classify_severity(significance=0.3, confidence=0.9) == SeverityLevel.MEDIUM
assert classify_severity(significance=0.1, confidence=0.9) == SeverityLevel.LOW
def test_classify_severity_critical_requires_high_confidence():
"""A score that would otherwise land in the Critical bucket (>= 0.6)
must be downgraded to High when confidence is below the floor - an
uncorroborated single-source signal can't carry the Critical label."""
score = 1.0 * 0.65
assert score >= 0.6 # would be CRITICAL by score alone
severity = classify_severity(significance=1.0, confidence=0.65)
assert severity == SeverityLevel.HIGH
def test_classify_severity_high_confidence_allows_critical():
score = 1.0 * 0.8
assert score >= 0.6
severity = classify_severity(significance=1.0, confidence=0.8)
assert severity == SeverityLevel.CRITICAL
@@ -0,0 +1,78 @@
"""SearchProvider: Mock (deterministic, honest about having no real
evidence) and Brave (respx-mocked REST call), plus the factory's routing."""
from __future__ import annotations
import httpx
import pytest
import respx
from app.core.config import Settings
from app.search.factory import get_search_provider
from app.search.mock import MockSearchProvider
@pytest.mark.asyncio
async def test_mock_provider_guesses_a_domain_for_official_website_queries():
provider = MockSearchProvider()
results = await provider.search("Acme Mobility official website")
assert len(results) == 1
assert results[0].url == "https://acmemobility.com"
assert "Acme Mobility" in results[0].title
@pytest.mark.asyncio
async def test_mock_provider_is_honest_about_no_evidence_for_other_queries():
provider = MockSearchProvider()
results = await provider.search("Acme Mobility competitors")
assert len(results) == 1
assert "no information available" in results[0].snippet.lower()
@pytest.mark.asyncio
async def test_mock_provider_respects_count():
provider = MockSearchProvider()
results = await provider.search("Acme official website", count=0)
assert results == []
@pytest.mark.asyncio
async def test_brave_provider_maps_api_response_to_search_results():
from app.search.brave import BraveSearchProvider
settings = Settings(search_provider="brave", brave_search_api_key="test-key")
provider = BraveSearchProvider(settings)
with respx.mock:
route = respx.get("https://api.search.brave.com/res/v1/web/search").mock(
return_value=httpx.Response(
200,
json={
"web": {
"results": [
{
"title": "Acme Mobility - Official Site",
"url": "https://acmemobility.com",
"description": "Acme Mobility builds electric scooters.",
}
]
}
},
)
)
results = await provider.search("Acme Mobility official website", count=3)
assert route.calls.last.request.headers["X-Subscription-Token"] == "test-key"
assert len(results) == 1
assert results[0].url == "https://acmemobility.com"
assert results[0].snippet == "Acme Mobility builds electric scooters."
def test_factory_routes_by_search_provider_setting():
mock_provider = get_search_provider(Settings(search_provider="mock"))
assert mock_provider.provider_name == "mock"
brave_provider = get_search_provider(Settings(search_provider="brave"))
assert brave_provider.provider_name == "brave"
+654
View File
@@ -0,0 +1,654 @@
"""End-to-end HTTP + service-level tests for Phase 19: email verification,
password reset, account lockout, and Turnstile enforcement.
Each test that exercises the IP-throttle-gated endpoints uses its own
synthetic client IP (`_unique_ip` + a fresh `TestClient(app, client=(ip,
...))`), rather than the shared `client` fixture's fake "testclient" peer -
sharing that IP across tests previously caused a real bug (register()
exhausting the resend-verification ladder and permanently banning
"testclient", see auth_service.register's docstring) and every test here
would risk reintroducing the same class of collision if it shared IPs.
The login-lockout ladder is walked at the service level (like
test_ip_throttle_service.py) with `ip_throttle_service._now` monkeypatched
forward, so 12 escalating attempts take milliseconds of real test time
instead of ~30 real minutes.
"""
from __future__ import annotations
import re
import uuid
from datetime import UTC, datetime, timedelta
import httpx
import pytest
import respx
from fastapi.testclient import TestClient
from app.core.config import get_settings
from app.core.errors import AuthenticationError
from app.db.base import ensure_aware_utc
from app.main import app
from app.models.enums import ThrottleAction
from app.notifications.base import DeliveryResult
from app.repositories.ip_throttle_repository import IpThrottleRepository
from app.repositories.user_repository import UserRepository
from app.schemas.auth import LoginRequest, RegisterRequest
from app.services import auth_service, ip_throttle_service
from app.services.turnstile_service import verify_turnstile
def _unique_email() -> str:
return f"user-{uuid.uuid4().hex[:12]}@example.com"
def _unique_ip() -> str:
# Randomize all three trailing octets (same convention as
# test_ip_throttle_service.py) - a single-octet range only has ~250
# values, which collides often enough across a full suite run (birthday
# paradox) to cause real, intermittent failures between unrelated tests
# that happen to share ip_throttle_state rows.
return f"10.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}"
def _install_fake_smtp(monkeypatch) -> list[dict]:
sent: list[dict] = []
class FakeSmtp:
def __init__(self, host, port, timeout=10):
pass
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def starttls(self):
pass
def login(self, username, password):
pass
def sendmail(self, from_addr, to_addrs, message):
sent.append({"to": to_addrs, "message": message})
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FakeSmtp)
return sent
def _extract_code(sent_emails: list[dict]) -> str:
message = sent_emails[-1]["message"]
match = re.search(r"code is[^\d]*(\d{6})", message)
assert match, f"no 6-digit code found in most recent sent email: {message!r}"
return match.group(1)
# --- Email verification --------------------------------------------------
def test_verify_email_wrong_code_is_generic_failure(client: TestClient):
resp = client.post(
"/api/v1/auth/verify-email", json={"email": _unique_email(), "code": "000000"}
)
assert resp.status_code == 401
def test_verify_email_code_guessing_throttled_after_five_attempts(monkeypatch):
"""A 6-digit code has only 1M possible values - without this, an
attacker could brute-force it well within its 36h validity window."""
_install_fake_smtp(monkeypatch)
ip = _unique_ip()
with TestClient(app, client=(ip, 51234)) as c:
email = _unique_email()
c.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
)
for _ in range(5):
resp = c.post("/api/v1/auth/verify-email", json={"email": email, "code": "000000"})
assert resp.status_code == 401
throttled = c.post("/api/v1/auth/verify-email", json={"email": email, "code": "000000"})
assert throttled.status_code == 429
# See the resend-verification test above for why this tolerates a
# 1-second real-clock rounding jitter.
assert throttled.json()["retry_after_seconds"] in (4, 5)
def test_confirm_password_reset_code_guessing_throttled_after_five_attempts(monkeypatch):
_install_fake_smtp(monkeypatch)
ip = _unique_ip()
with TestClient(app, client=(ip, 51234)) as c:
email = _unique_email()
c.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
)
c.post("/api/v1/auth/request-password-reset", json={"email": email})
for _ in range(5):
resp = c.post(
"/api/v1/auth/confirm-password-reset",
json={"email": email, "code": "000000", "new_password": "new-horse-2"},
)
assert resp.status_code == 401
throttled = c.post(
"/api/v1/auth/confirm-password-reset",
json={"email": email, "code": "000000", "new_password": "new-horse-2"},
)
assert throttled.status_code == 429
assert throttled.json()["retry_after_seconds"] in (4, 5)
async def test_login_blocked_until_verified_then_succeeds_after_verify_email(monkeypatch):
sent = _install_fake_smtp(monkeypatch)
ip = _unique_ip()
with TestClient(app, client=(ip, 51234)) as c:
email = _unique_email()
c.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
)
# Test env auto-verifies on register (no real inbox to read from) -
# flip it back off directly to exercise the actual gate.
from app.db.session import get_sessionmaker
session_factory = get_sessionmaker()
async with session_factory() as db:
user = await UserRepository(db).get_by_email(email)
user.email_verified = False
await db.commit()
blocked = c.post("/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"})
assert blocked.status_code == 401
assert "verify" in blocked.json()["detail"].lower()
code = _extract_code(sent)
verify_resp = c.post("/api/v1/auth/verify-email", json={"email": email, "code": code})
assert verify_resp.status_code == 204
allowed = c.post("/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"})
assert allowed.status_code == 200
async def test_verify_email_old_code_invalidated_by_resend(monkeypatch):
"""A resend must fully supersede the prior code, not just make it
harder to guess - the old one must stop working entirely."""
sent = _install_fake_smtp(monkeypatch)
ip = _unique_ip()
with TestClient(app, client=(ip, 51234)) as c:
email = _unique_email()
c.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
)
old_code = _extract_code(sent)
# Test env auto-verifies on register, which would make resend() a
# no-op (it only resends for a not-yet-verified account) - flip it
# back off directly so a genuine second code actually gets issued.
from app.db.session import get_sessionmaker
session_factory = get_sessionmaker()
async with session_factory() as db:
user = await UserRepository(db).get_by_email(email)
user.email_verified = False
await db.commit()
c.post("/api/v1/auth/resend-verification", json={"email": email})
new_code = _extract_code(sent)
stale = c.post("/api/v1/auth/verify-email", json={"email": email, "code": old_code})
assert stale.status_code == 401
fresh = c.post("/api/v1/auth/verify-email", json={"email": email, "code": new_code})
assert fresh.status_code == 204
def test_resend_verification_throttled_immediately_after_first_click(monkeypatch):
_install_fake_smtp(monkeypatch)
ip = _unique_ip()
with TestClient(app, client=(ip, 51234)) as c:
email = _unique_email()
c.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
)
first = c.post("/api/v1/auth/resend-verification", json={"email": email})
assert first.status_code == 204
second = c.post("/api/v1/auth/resend-verification", json={"email": email})
assert second.status_code == 429
# retry_after_seconds is int((next_allowed_at - now).total_seconds()),
# computed against the real clock here - a few ms of real elapsed
# time between the two HTTP calls can round it down from 30 to 29.
retry_after = second.json()["retry_after_seconds"]
assert retry_after in (29, 30)
assert second.headers["retry-after"] == str(retry_after)
def test_resend_verification_unknown_email_is_generic_success(monkeypatch):
sent = _install_fake_smtp(monkeypatch)
ip = _unique_ip()
with TestClient(app, client=(ip, 51234)) as c:
resp = c.post("/api/v1/auth/resend-verification", json={"email": _unique_email()})
assert resp.status_code == 204
assert sent == [] # no account -> nothing actually sent, but no enumeration signal either
# --- Password reset --------------------------------------------------------
def test_password_reset_old_code_invalidated_by_new_request(monkeypatch):
sent = _install_fake_smtp(monkeypatch)
email = _unique_email()
ip1, ip2 = _unique_ip(), _unique_ip()
with TestClient(app, client=(ip1, 51234)) as c1:
c1.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
)
c1.post("/api/v1/auth/request-password-reset", json={"email": email})
old_code = _extract_code(sent)
with TestClient(app, client=(ip2, 51235)) as c2:
c2.post("/api/v1/auth/request-password-reset", json={"email": email})
new_code = _extract_code(sent)
stale = c2.post(
"/api/v1/auth/confirm-password-reset",
json={"email": email, "code": old_code, "new_password": "new-horse-2"},
)
assert stale.status_code == 401
fresh = c2.post(
"/api/v1/auth/confirm-password-reset",
json={"email": email, "code": new_code, "new_password": "new-horse-2"},
)
assert fresh.status_code == 204
def test_password_reset_rejects_reusing_current_password(monkeypatch):
sent = _install_fake_smtp(monkeypatch)
ip = _unique_ip()
with TestClient(app, client=(ip, 51234)) as c:
email = _unique_email()
c.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
)
c.post("/api/v1/auth/request-password-reset", json={"email": email})
code = _extract_code(sent)
rejected = c.post(
"/api/v1/auth/confirm-password-reset",
json={"email": email, "code": code, "new_password": "correct-horse-1"},
)
assert rejected.status_code == 400
assert "used this password before" in rejected.json()["detail"].lower()
# Rejection must not consume the code - it still works with a
# genuinely different password right after.
retry = c.post(
"/api/v1/auth/confirm-password-reset",
json={"email": email, "code": code, "new_password": "different-horse-9"},
)
assert retry.status_code == 204
def test_password_reset_rejects_reusing_a_previous_not_just_current_password(monkeypatch):
sent = _install_fake_smtp(monkeypatch)
email = _unique_email()
ip1, ip2 = _unique_ip(), _unique_ip()
with TestClient(app, client=(ip1, 51234)) as c1:
c1.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
)
c1.post("/api/v1/auth/request-password-reset", json={"email": email})
code1 = _extract_code(sent)
first = c1.post(
"/api/v1/auth/confirm-password-reset",
json={"email": email, "code": code1, "new_password": "new-horse-2"},
)
assert first.status_code == 204
with TestClient(app, client=(ip2, 51235)) as c2:
c2.post("/api/v1/auth/request-password-reset", json={"email": email})
code2 = _extract_code(sent)
# correct-horse-1 is no longer the current password, but it's still
# in this account's history - must still be rejected.
rejected = c2.post(
"/api/v1/auth/confirm-password-reset",
json={"email": email, "code": code2, "new_password": "correct-horse-1"},
)
assert rejected.status_code == 400
accepted = c2.post(
"/api/v1/auth/confirm-password-reset",
json={"email": email, "code": code2, "new_password": "third-horse-3"},
)
assert accepted.status_code == 204
def test_password_reset_full_round_trip_then_old_sessions_revoked(monkeypatch):
sent = _install_fake_smtp(monkeypatch)
ip = _unique_ip()
with TestClient(app, client=(ip, 51234)) as c:
email = _unique_email()
c.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
)
tokens = c.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
reset_req = c.post("/api/v1/auth/request-password-reset", json={"email": email})
assert reset_req.status_code == 204
code = _extract_code(sent)
confirm = c.post(
"/api/v1/auth/confirm-password-reset",
json={"email": email, "code": code, "new_password": "new-horse-2"},
)
assert confirm.status_code == 204
old_password_login = c.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
)
assert old_password_login.status_code == 401
new_password_login = c.post(
"/api/v1/auth/login", json={"email": email, "password": "new-horse-2"}
)
assert new_password_login.status_code == 200
# A reset invalidates sessions that existed before it.
stale_refresh = c.post(
"/api/v1/auth/refresh", json={"refresh_token": tokens["refresh_token"]}
)
assert stale_refresh.status_code == 401
def test_request_password_reset_unknown_email_is_generic_success(monkeypatch):
sent = _install_fake_smtp(monkeypatch)
ip = _unique_ip()
with TestClient(app, client=(ip, 51234)) as c:
resp = c.post("/api/v1/auth/request-password-reset", json={"email": _unique_email()})
assert resp.status_code == 204
assert sent == []
def test_confirm_password_reset_wrong_code_is_generic_failure(client: TestClient):
resp = client.post(
"/api/v1/auth/confirm-password-reset",
json={"email": _unique_email(), "code": "000000", "new_password": "new-horse-2"},
)
assert resp.status_code == 401
# --- Account lockout (service-level, clock-walked) -------------------------
class _Clock:
def __init__(self) -> None:
self.now = datetime(2026, 1, 1, tzinfo=UTC)
def __call__(self) -> datetime:
return self.now
def advance(self, seconds: float) -> None:
self.now += timedelta(seconds=seconds)
@pytest.fixture()
def clock(monkeypatch):
fake_clock = _Clock()
monkeypatch.setattr(ip_throttle_service, "_now", fake_clock)
return fake_clock
async def _advance_past_login_throttle(db_session, clock: _Clock, ip: str) -> None:
peeked = await ip_throttle_service.peek_throttle(db_session, ip, ThrottleAction.FAILED_LOGIN)
if not peeked.allowed and peeked.retry_after_seconds:
clock.advance(peeked.retry_after_seconds)
elif not peeked.allowed:
# Banned outright (no retry_after) - shouldn't happen within this
# test's 12-attempt ladder, but fail loudly if it ever does.
state = await IpThrottleRepository(db_session).get_state(ip, ThrottleAction.FAILED_LOGIN)
if state and state.timeout_until is not None:
clock.advance((ensure_aware_utc(state.timeout_until) - clock.now).total_seconds() + 1)
async def test_login_lockout_after_twelve_failed_attempts_locks_account_and_notifies(
db_session, clock, monkeypatch
):
settings = get_settings()
ip = _unique_ip()
email = _unique_email()
user = await auth_service.register(
db_session,
settings,
ip,
RegisterRequest(email=email, password="correct-horse-1", display_name="T"),
)
assert user.email_verified is True # app_env == "test" auto-verify precedent
locked_emails: list[str] = []
async def fake_send_locked(_settings, to):
locked_emails.append(to)
return DeliveryResult(success=True)
monkeypatch.setattr(
auth_service.security_email_service, "send_account_locked_email", fake_send_locked
)
wrong_login = LoginRequest(email=email, password="wrong-password-1")
for _ in range(12):
await _advance_past_login_throttle(db_session, clock, ip)
with pytest.raises(AuthenticationError):
await auth_service.login(db_session, settings, ip, wrong_login)
refreshed = await UserRepository(db_session).get_by_id(user.id)
assert refreshed.failed_login_count == 12
assert refreshed.locked_at is not None
assert locked_emails == [email]
# The account stays locked even with the *correct* password, and even
# once the IP itself is no longer throttled.
await _advance_past_login_throttle(db_session, clock, ip)
correct_login = LoginRequest(email=email, password="correct-horse-1")
with pytest.raises(AuthenticationError, match="locked"):
await auth_service.login(db_session, settings, ip, correct_login)
async def test_login_correct_password_resets_failed_count_before_lockout(db_session, clock):
settings = get_settings()
ip = _unique_ip()
email = _unique_email()
user = await auth_service.register(
db_session,
settings,
ip,
RegisterRequest(email=email, password="correct-horse-1", display_name="T"),
)
wrong_login = LoginRequest(email=email, password="wrong-password-1")
for _ in range(3):
await _advance_past_login_throttle(db_session, clock, ip)
with pytest.raises(AuthenticationError):
await auth_service.login(db_session, settings, ip, wrong_login)
await _advance_past_login_throttle(db_session, clock, ip)
correct_login = LoginRequest(email=email, password="correct-horse-1")
await auth_service.login(db_session, settings, ip, correct_login)
refreshed = await UserRepository(db_session).get_by_id(user.id)
assert refreshed.failed_login_count == 0
assert refreshed.locked_at is None
# --- Turnstile ---------------------------------------------------------
def test_register_without_turnstile_token_rejected_when_configured(client: TestClient):
settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"})
app.dependency_overrides[get_settings] = lambda: settings
try:
resp = client.post(
"/api/v1/auth/register",
json={"email": _unique_email(), "password": "correct-horse-1", "display_name": "T"},
)
assert resp.status_code == 400
finally:
app.dependency_overrides.pop(get_settings, None)
def test_register_with_verified_turnstile_token_succeeds(client: TestClient, monkeypatch):
settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"})
app.dependency_overrides[get_settings] = lambda: settings
async def fake_verify(token, remote_ip, _settings):
assert token == "good-token"
return True
monkeypatch.setattr("app.api.v1.auth.verify_turnstile", fake_verify)
try:
resp = client.post(
"/api/v1/auth/register",
json={
"email": _unique_email(),
"password": "correct-horse-1",
"display_name": "T",
"turnstile_token": "good-token",
},
)
assert resp.status_code == 201
finally:
app.dependency_overrides.pop(get_settings, None)
def test_register_skips_turnstile_entirely_on_localhost_even_when_configured():
settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"})
app.dependency_overrides[get_settings] = lambda: settings
try:
with TestClient(app, client=("127.0.0.1", 54321)) as loopback_client:
resp = loopback_client.post(
"/api/v1/auth/register",
json={
"email": _unique_email(),
"password": "correct-horse-1",
"display_name": "T",
},
)
assert resp.status_code == 201
finally:
app.dependency_overrides.pop(get_settings, None)
async def test_register_succeeds_for_non_localhost_caller_when_configured_secret_is_invalid(
client: TestClient,
):
"""End-to-end proof (not just verify_turnstile in isolation): a
non-loopback caller can still register when the admin's configured
secret is itself broken, regardless of what token they submitted."""
settings = get_settings().model_copy(update={"turnstile_secret": "a-typo-d-secret"})
app.dependency_overrides[get_settings] = lambda: settings
try:
with respx.mock:
respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock(
return_value=httpx.Response(
200, json={"success": False, "error-codes": ["invalid-input-secret"]}
)
)
resp = client.post(
"/api/v1/auth/register",
json={
"email": _unique_email(),
"password": "correct-horse-1",
"display_name": "T",
"turnstile_token": "whatever-token",
},
)
assert resp.status_code == 201
finally:
app.dependency_overrides.pop(get_settings, None)
async def test_verify_turnstile_returns_true_on_cloudflare_success():
settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"})
with respx.mock:
respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock(
return_value=httpx.Response(200, json={"success": True})
)
result = await verify_turnstile("some-token", "1.2.3.4", settings)
assert result is True
async def test_verify_turnstile_fails_closed_on_network_error():
settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"})
with respx.mock:
respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock(
side_effect=httpx.ConnectError("boom")
)
result = await verify_turnstile("some-token", "1.2.3.4", settings)
assert result is False
async def test_verify_turnstile_fails_open_when_the_configured_secret_itself_is_invalid():
"""A typo'd/invalid secret is a detectable config problem (Cloudflare
reports it via error-codes), not an ambiguous failure - locking out
every real visitor over an admin's own mistake is worse than briefly
running with reduced bot protection."""
settings = get_settings().model_copy(update={"turnstile_secret": "a-typo-d-secret"})
with respx.mock:
respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock(
return_value=httpx.Response(
200, json={"success": False, "error-codes": ["invalid-input-secret"]}
)
)
result = await verify_turnstile("some-token", "1.2.3.4", settings)
assert result is True
async def test_verify_turnstile_still_fails_closed_for_a_genuinely_bad_user_token():
"""The fail-open carve-out is scoped to secret-level error codes only -
a real rejection of the user's own token must still fail closed."""
settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"})
with respx.mock:
respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock(
return_value=httpx.Response(
200, json={"success": False, "error-codes": ["invalid-input-response"]}
)
)
result = await verify_turnstile("bad-token", "1.2.3.4", settings)
assert result is False
# --- IP ban -----------------------------------------------------------------
async def test_register_rejected_when_ip_is_banned(db_session):
"""A ban is IP-global - it must block brand-new account creation too,
not just actions against existing accounts (login/resend/reset)."""
ip = _unique_ip()
await IpThrottleRepository(db_session).create_ban(ip, "failed_login", datetime.now(UTC))
await db_session.commit()
with TestClient(app, client=(ip, 51234)) as c:
resp = c.post(
"/api/v1/auth/register",
json={"email": _unique_email(), "password": "correct-horse-1", "display_name": "T"},
)
assert resp.status_code == 429
assert resp.json()["detail"] == "This IP address has been temporarily blocked."
+98
View File
@@ -0,0 +1,98 @@
"""Snapshots API: read-only history listing, newest-first, ownership-scoped.
Snapshots have no creation endpoint (they're written internally by
collection_service.py during a monitoring run), so tests insert one directly
via db_session against the same DB the `client` fixture's TestClient uses."""
from __future__ import annotations
import uuid
import pytest
from app.models.snapshot import Snapshot
def _register_and_login(client) -> dict[str, str]:
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
def _create_company(client, headers):
return client.post(
"/api/v1/companies",
json={"name": f"Co {uuid.uuid4().hex[:6]}", "frequency_type": "weekly"},
headers=headers,
).json()
def _create_source(client, headers, company_id):
return client.post(
f"/api/v1/companies/{company_id}/sources",
json={"source_type": "custom_url", "name": "Pricing", "base_url": "https://example.com"},
headers=headers,
).json()
def test_snapshots_empty_before_any_collection(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
resp = client.get(f"/api/v1/companies/{company['id']}/snapshots", headers=headers)
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_snapshots_list_newest_first(client, db_session):
headers = _register_and_login(client)
company = _create_company(client, headers)
source = _create_source(client, headers, company["id"])
older = Snapshot(
company_id=uuid.UUID(company["id"]),
source_id=uuid.UUID(source["id"]),
snapshot_type="website",
hash="hash-older",
structured_summary={"title": "Old"},
text_summary="Old page text",
)
db_session.add(older)
await db_session.commit()
newer = Snapshot(
company_id=uuid.UUID(company["id"]),
source_id=uuid.UUID(source["id"]),
snapshot_type="website",
hash="hash-newer",
structured_summary={"title": "New"},
text_summary="New page text",
)
db_session.add(newer)
await db_session.commit()
resp = client.get(f"/api/v1/companies/{company['id']}/snapshots", headers=headers)
assert resp.status_code == 200
body = resp.json()
assert len(body) == 2
assert body[0]["hash"] == "hash-newer"
assert body[1]["hash"] == "hash-older"
assert body[0]["text_summary"] == "New page text"
def test_snapshots_scoped_to_owner(client):
owner_headers = _register_and_login(client)
other_headers = _register_and_login(client)
company = _create_company(client, owner_headers)
resp = client.get(f"/api/v1/companies/{company['id']}/snapshots", headers=other_headers)
assert resp.status_code == 404
+190
View File
@@ -0,0 +1,190 @@
"""Sources API: ownership isolation, user-creatable type restriction, and
the ad-hoc test action - via HTTP, with respx mocking the network call the
test action makes."""
from __future__ import annotations
import uuid
from unittest.mock import patch
import httpx
import respx
def _register_and_login(client) -> dict[str, str]:
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
def _create_company(client, headers):
return client.post(
"/api/v1/companies",
json={"name": f"Co {uuid.uuid4().hex[:6]}", "frequency_type": "weekly"},
headers=headers,
).json()
def test_create_custom_url_source(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
resp = client.post(
f"/api/v1/companies/{company['id']}/sources",
json={"source_type": "custom_url", "name": "Pricing", "base_url": "example.com/pricing"},
headers=headers,
)
assert resp.status_code == 201
body = resp.json()
assert body["base_url"] == "https://example.com/pricing"
assert body["status"] == "active"
def test_create_source_rejects_non_user_creatable_type(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
resp = client.post(
f"/api/v1/companies/{company['id']}/sources",
json={"source_type": "github", "name": "GitHub", "base_url": "https://github.com/acme"},
headers=headers,
)
assert resp.status_code == 422
def test_sources_scoped_to_owner(client):
owner_headers = _register_and_login(client)
other_headers = _register_and_login(client)
company = _create_company(client, owner_headers)
created = client.post(
f"/api/v1/companies/{company['id']}/sources",
json={
"source_type": "custom_url",
"name": "Pricing",
"base_url": "https://example.com/pricing",
},
headers=owner_headers,
).json()
# Another user can't list this company's sources...
resp = client.get(f"/api/v1/companies/{company['id']}/sources", headers=other_headers)
assert resp.status_code == 404
# ...or update/delete the source directly.
resp = client.patch(
f"/api/v1/sources/{created['id']}", json={"active": False}, headers=other_headers
)
assert resp.status_code == 404
def test_update_and_delete_source(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
created = client.post(
f"/api/v1/companies/{company['id']}/sources",
json={
"source_type": "custom_url",
"name": "Pricing",
"base_url": "https://example.com/pricing",
},
headers=headers,
).json()
resp = client.patch(f"/api/v1/sources/{created['id']}", json={"active": False}, headers=headers)
assert resp.status_code == 200
assert resp.json()["active"] is False
resp = client.delete(f"/api/v1/sources/{created['id']}", headers=headers)
assert resp.status_code == 204
resp = client.get(f"/api/v1/companies/{company['id']}/sources", headers=headers)
assert resp.json() == []
def test_update_source_sets_a_frequency_override(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
created = client.post(
f"/api/v1/companies/{company['id']}/sources",
json={
"source_type": "custom_url",
"name": "Pricing",
"base_url": "https://example.com/pricing",
},
headers=headers,
).json()
resp = client.patch(
f"/api/v1/sources/{created['id']}", json={"frequency_type": "daily"}, headers=headers
)
assert resp.status_code == 200
body = resp.json()
assert body["frequency_type"] == "daily"
assert body["next_check"] is None # takes effect on the next scheduler tick
# Clearing the override back to "same as company" is an explicit null.
resp = client.patch(
f"/api/v1/sources/{created['id']}", json={"frequency_type": None}, headers=headers
)
assert resp.status_code == 200
assert resp.json()["frequency_type"] is None
def test_update_source_rejects_a_custom_frequency_below_the_minimum_interval(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
created = client.post(
f"/api/v1/companies/{company['id']}/sources",
json={
"source_type": "custom_url",
"name": "Pricing",
"base_url": "https://example.com/pricing",
},
headers=headers,
).json()
resp = client.patch(
f"/api/v1/sources/{created['id']}",
json={"frequency_type": "custom", "interval_minutes": 1},
headers=headers,
)
assert resp.status_code == 400
def test_source_test_action_runs_a_real_collection(client):
headers = _register_and_login(client)
company = _create_company(client, headers)
created = client.post(
f"/api/v1/companies/{company['id']}/sources",
json={
"source_type": "custom_url",
"name": "Pricing",
"base_url": "https://example.com/pricing",
},
headers=headers,
).json()
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
with respx.mock:
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
respx.get("https://example.com/pricing").mock(
return_value=httpx.Response(
200,
html="<html><head><title>Pricing</title></head><body>"
"<article><h1>Pricing</h1><p>Plans start at $10/month.</p></article>"
"</body></html>",
)
)
resp = client.post(f"/api/v1/sources/{created['id']}/test", headers=headers)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "active"
assert body["documents_found"] == 1
+77
View File
@@ -0,0 +1,77 @@
"""SSRF guard tests - see SECURITY.md."""
from __future__ import annotations
from unittest.mock import patch
import httpx
import pytest
import respx
from app.core.http import SsrfBlockedError, safe_fetch, validate_url
def test_validate_url_rejects_disallowed_scheme():
with pytest.raises(SsrfBlockedError):
validate_url("file:///etc/passwd")
def test_validate_url_rejects_url_with_no_hostname():
with pytest.raises(SsrfBlockedError):
validate_url("http://")
@pytest.mark.parametrize(
"hostname,ip",
[
("localhost-test", "127.0.0.1"),
("private-test", "10.0.0.5"),
("private-test-2", "192.168.1.1"),
("link-local-test", "169.254.1.1"),
("metadata-test", "169.254.169.254"),
],
)
def test_validate_url_blocks_private_and_metadata_addresses(hostname, ip):
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", (ip, 0))]):
with pytest.raises(SsrfBlockedError):
validate_url(f"http://{hostname}/")
def test_validate_url_allows_public_address():
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
validate_url("http://example.com/") # should not raise
@pytest.mark.asyncio
async def test_safe_fetch_revalidates_each_redirect_hop():
"""A redirect to a private address must be blocked even if the initial
URL resolves to a public one."""
with patch("socket.getaddrinfo") as mock_resolve:
def resolve(hostname, *_args, **_kwargs):
if hostname == "public.example":
return [(2, 1, 6, "", ("93.184.216.34", 0))]
if hostname == "internal.example":
return [(2, 1, 6, "", ("10.0.0.5", 0))]
raise AssertionError(f"unexpected hostname {hostname}")
mock_resolve.side_effect = resolve
with respx.mock:
respx.get("http://public.example/").mock(
return_value=httpx.Response(302, headers={"Location": "http://internal.example/"})
)
with pytest.raises(SsrfBlockedError):
await safe_fetch("http://public.example/")
@pytest.mark.asyncio
async def test_safe_fetch_returns_final_response_body():
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
with respx.mock:
respx.get("http://public.example/").mock(
return_value=httpx.Response(200, text="hello world")
)
result = await safe_fetch("http://public.example/")
assert result.status_code == 200
assert result.text == "hello world"
@@ -0,0 +1,26 @@
from __future__ import annotations
from app.change_detection.structured_diff import diff_item_sets
def test_diff_item_sets_detects_additions_and_removals():
previous = ["https://x.com/a", "https://x.com/b"]
current = ["https://x.com/b", "https://x.com/c"]
diff = diff_item_sets(previous, current)
assert diff.added == ["https://x.com/c"]
assert diff.removed == ["https://x.com/a"]
assert diff.has_changes is True
def test_diff_item_sets_no_change_when_identical():
items = ["https://x.com/a", "https://x.com/b"]
diff = diff_item_sets(items, list(items))
assert diff.added == []
assert diff.removed == []
assert diff.has_changes is False
def test_diff_item_sets_handles_empty_previous():
diff = diff_item_sets([], ["https://x.com/a"])
assert diff.added == ["https://x.com/a"]
assert diff.removed == []
@@ -0,0 +1,121 @@
"""/system/status and /system/logs - especially that /system/logs is
admin-only (Phase 19 - it exposes operational internals, not something any
registered user should read) and that the live log feed actually captures
what the app logs. Server-wide secret management (Turnstile site
key/secret) moved to /system/secrets - see test_system_secrets.py."""
from __future__ import annotations
import uuid
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.logging import get_logger
from app.main import app
from app.repositories.user_repository import UserRepository
def _register_and_login(client: TestClient) -> dict[str, str]:
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
async def _register_admin_and_login(client: TestClient, db_session: AsyncSession) -> dict[str, str]:
"""Registration never accepts is_admin from the client - promote
directly in the DB, the same way a real operator would via a one-off
script/console, not through any HTTP-exposed path."""
email = f"admin-{uuid.uuid4().hex[:12]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Admin User"},
)
user = await UserRepository(db_session).get_by_email(email)
user.is_admin = True
await db_session.commit()
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
def test_system_status_reports_not_localhost_for_default_test_client(client: TestClient):
# Starlette's TestClient defaults its ASGI scope client to
# ("testclient", 50000), not a loopback address - this is the "someone
# not on this machine" case.
resp = client.get("/api/v1/system/status")
assert resp.status_code == 200
assert resp.json()["is_localhost"] is False
def test_system_status_reports_localhost_for_loopback_client():
with TestClient(app, client=("127.0.0.1", 54321)) as loopback_client:
resp = loopback_client.get("/api/v1/system/status")
assert resp.status_code == 200
assert resp.json()["is_localhost"] is True
def test_system_status_treats_configured_extra_ip_as_localhost():
# Docker Desktop's bridge networking means host-originated traffic
# never arrives as literal loopback - additional_trusted_local_ips is
# the opt-in escape hatch for that, see app.core.security.is_localhost.
test_settings = get_settings().model_copy(
update={"additional_trusted_local_ips": "172.18.0.1, 10.0.0.5"}
)
app.dependency_overrides[get_settings] = lambda: test_settings
try:
with TestClient(app, client=("172.18.0.1", 54321)) as bridge_client:
resp = bridge_client.get("/api/v1/system/status")
assert resp.status_code == 200
assert resp.json()["is_localhost"] is True
finally:
app.dependency_overrides.pop(get_settings, None)
def test_system_status_does_not_trust_an_unlisted_ip():
test_settings = get_settings().model_copy(update={"additional_trusted_local_ips": "172.18.0.1"})
app.dependency_overrides[get_settings] = lambda: test_settings
try:
with TestClient(app, client=("203.0.113.9", 54321)) as stranger_client:
resp = stranger_client.get("/api/v1/system/status")
assert resp.status_code == 200
assert resp.json()["is_localhost"] is False
finally:
app.dependency_overrides.pop(get_settings, None)
async def test_system_logs_captures_and_categorizes_real_log_calls(
client: TestClient, db_session: AsyncSession
):
headers = await _register_admin_and_login(client, db_session)
marker = f"phase17-test-marker-{uuid.uuid4().hex[:8]}"
logger = get_logger("tests.system_logs")
logger.warning("test_api_style_failure", event_marker=marker)
resp = client.get("/api/v1/system/logs", headers=headers)
assert resp.status_code == 200
entries = resp.json()
match = next(e for e in entries if e["context"].get("event_marker") == marker)
assert match["category"] == "api_error"
assert match["level"] == "warning"
assert match["event"] == "test_api_style_failure"
def test_system_logs_requires_auth(client: TestClient):
resp = client.get("/api/v1/system/logs")
assert resp.status_code == 401
def test_system_logs_non_admin_forbidden(client: TestClient):
headers = _register_and_login(client)
resp = client.get("/api/v1/system/logs", headers=headers)
assert resp.status_code == 403
+294
View File
@@ -0,0 +1,294 @@
"""Server-wide secrets (Turnstile site key/secret) admin-managed from the
Settings page instead of only .env: repository/service behavior, endpoint
auth/admin-gating (deliberately NOT localhost-gated, unlike the old
/system/api-keys this replaces), /system/status exposing the site key live,
and an end-to-end proof that a DB-only (no .env) secret actually drives
Turnstile enforcement on register."""
from __future__ import annotations
import uuid
import httpx
import pytest
import respx
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.models.enums import SystemSecretKey
from app.repositories.system_secret_repository import SystemSecretRepository
from app.repositories.user_repository import UserRepository
from app.services import system_secret_service
@pytest.fixture(autouse=True)
async def _clean_system_secrets(db_session):
"""SystemSecret rows are true global singletons (one per key, not
per-user like UserApiKey) - unlike other tests in this suite that
dodge cross-test pollution by randomizing an id, there's no such trick
here. Every test starts from a clean slate, and leaves one behind for
whatever test file runs next in a full-suite run."""
async def _clear() -> None:
repo = SystemSecretRepository(db_session)
for row in await repo.list_all():
await db_session.delete(row)
await db_session.commit()
await _clear()
yield
await _clear()
def _unique_email() -> str:
return f"user-{uuid.uuid4().hex[:12]}@example.com"
def _register_and_login(client: TestClient) -> dict[str, str]:
email = _unique_email()
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
async def _register_admin_and_login(client: TestClient, db_session: AsyncSession) -> dict[str, str]:
email = f"admin-{uuid.uuid4().hex[:12]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Admin"},
)
user = await UserRepository(db_session).get_by_email(email)
user.is_admin = True
await db_session.commit()
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
# --- Repository --------------------------------------------------------
async def test_repository_upsert_then_get_then_delete(db_session):
repo = SystemSecretRepository(db_session)
await repo.upsert(SystemSecretKey.TURNSTILE_SECRET, "encrypted-1")
row = await repo.get(SystemSecretKey.TURNSTILE_SECRET)
assert row is not None
assert row.encrypted_value == "encrypted-1"
await repo.upsert(SystemSecretKey.TURNSTILE_SECRET, "encrypted-2")
row = await repo.get(SystemSecretKey.TURNSTILE_SECRET)
assert row.encrypted_value == "encrypted-2" # updated in place, not duplicated
await repo.delete(SystemSecretKey.TURNSTILE_SECRET)
assert await repo.get(SystemSecretKey.TURNSTILE_SECRET) is None
# --- Service -------------------------------------------------------------
async def test_list_status_shows_both_keys_unconfigured_by_default(db_session):
settings = get_settings()
statuses = await system_secret_service.list_status(db_session, settings)
assert {s["key"] for s in statuses} == {"turnstile_site_key", "turnstile_secret"}
assert all(s["configured"] is False for s in statuses)
assert all(s["value"] is None for s in statuses)
async def test_set_secret_then_list_status_shows_it_configured(db_session):
settings = get_settings()
await system_secret_service.set_secret(
db_session,
SystemSecretKey.TURNSTILE_SITE_KEY,
"0x-my-site-key",
settings,
admin_user_id=uuid.uuid4(),
client_ip="10.0.0.1",
)
statuses = await system_secret_service.list_status(db_session, settings)
site_key = next(s for s in statuses if s["key"] == "turnstile_site_key")
assert site_key["configured"] is True
assert site_key["value"] == "0x-my-site-key"
async def test_set_blank_secret_clears_a_previously_set_one(db_session):
settings = get_settings()
await system_secret_service.set_secret(
db_session,
SystemSecretKey.TURNSTILE_SECRET,
"0x-my-secret",
settings,
admin_user_id=uuid.uuid4(),
client_ip="10.0.0.1",
)
await system_secret_service.set_secret(
db_session,
SystemSecretKey.TURNSTILE_SECRET,
" ",
settings,
admin_user_id=uuid.uuid4(),
client_ip="10.0.0.1",
)
statuses = await system_secret_service.list_status(db_session, settings)
secret = next(s for s in statuses if s["key"] == "turnstile_secret")
assert secret["configured"] is False
assert secret["value"] is None
async def test_get_effective_settings_falls_back_to_global_when_unset(db_session):
settings = get_settings().model_copy(update={"turnstile_site_key": "global-site-key"})
effective = await system_secret_service.get_effective_settings(db_session, settings)
assert effective.turnstile_site_key == "global-site-key"
async def test_get_effective_settings_overrides_only_the_keys_that_were_set(db_session):
settings = get_settings().model_copy(
update={"turnstile_site_key": "global-site-key", "turnstile_secret": "global-secret"}
)
await system_secret_service.set_secret(
db_session,
SystemSecretKey.TURNSTILE_SITE_KEY,
"admin-site-key",
settings,
admin_user_id=uuid.uuid4(),
client_ip="10.0.0.1",
)
effective = await system_secret_service.get_effective_settings(db_session, settings)
assert effective.turnstile_site_key == "admin-site-key"
assert effective.turnstile_secret == "global-secret" # untouched, no override set
# --- Endpoints -----------------------------------------------------------
def test_list_system_secrets_requires_auth(client: TestClient):
resp = client.get("/api/v1/system/secrets")
assert resp.status_code == 401
def test_list_system_secrets_non_admin_forbidden(client: TestClient):
headers = _register_and_login(client)
resp = client.get("/api/v1/system/secrets", headers=headers)
assert resp.status_code == 403
async def test_admin_can_list_and_set_secrets_even_from_a_non_loopback_client(
client: TestClient, db_session: AsyncSession
):
"""Deliberately different from the old /system/api-keys this replaces:
admin-gated only, no additional is_localhost requirement - the default
TestClient here has a non-loopback fake peer."""
headers = await _register_admin_and_login(client, db_session)
initial = client.get("/api/v1/system/secrets", headers=headers)
assert initial.status_code == 200
assert all(not s["configured"] for s in initial.json())
set_resp = client.put(
"/api/v1/system/secrets/turnstile_secret",
json={"value": "sk-set-via-api"},
headers=headers,
)
assert set_resp.status_code == 200
assert set_resp.json()["configured"] is True
assert set_resp.json()["value"] == "sk-set-via-api"
after = client.get("/api/v1/system/secrets", headers=headers)
secret = next(s for s in after.json() if s["key"] == "turnstile_secret")
assert secret["configured"] is True
assert secret["value"] == "sk-set-via-api"
async def test_updating_a_server_secret_is_logged_to_the_acting_admins_account_activity(
client: TestClient, db_session: AsyncSession
):
headers = await _register_admin_and_login(client, db_session)
client.put(
"/api/v1/system/secrets/turnstile_secret",
json={"value": "sk-set-via-api"},
headers=headers,
)
events = client.get("/api/v1/auth/security-events", headers=headers).json()
assert any(e["event_type"] == "server_secret_updated" for e in events)
async def test_set_system_secret_rejects_unknown_key(client: TestClient, db_session: AsyncSession):
headers = await _register_admin_and_login(client, db_session)
resp = client.put("/api/v1/system/secrets/not-a-real-key", json={"value": "x"}, headers=headers)
assert resp.status_code == 422
# --- /system/status exposes the site key live -------------------------
async def test_system_status_exposes_admin_configured_turnstile_site_key(
client: TestClient, db_session: AsyncSession
):
settings = get_settings()
await system_secret_service.set_secret(
db_session,
SystemSecretKey.TURNSTILE_SITE_KEY,
"admin-set-site-key",
settings,
admin_user_id=uuid.uuid4(),
client_ip="10.0.0.1",
)
resp = client.get("/api/v1/system/status")
assert resp.status_code == 200
assert resp.json()["turnstile_site_key"] == "admin-set-site-key"
def test_system_status_turnstile_site_key_is_null_when_unconfigured(client: TestClient):
resp = client.get("/api/v1/system/status")
assert resp.status_code == 200
assert resp.json()["turnstile_site_key"] is None
# --- End-to-end: a DB-only secret (no .env value) drives enforcement ----
async def test_register_requires_turnstile_when_only_db_configured_secret_exists(
client: TestClient, db_session: AsyncSession
):
"""Proves _enforce_turnstile actually resolves effective (DB-aware)
settings, not just the raw .env-backed global Settings object."""
settings = get_settings()
assert not settings.turnstile_secret # sanity: nothing set in .env for this test run
await system_secret_service.set_secret(
db_session,
SystemSecretKey.TURNSTILE_SECRET,
"admin-set-secret",
settings,
admin_user_id=uuid.uuid4(),
client_ip="10.0.0.1",
)
no_token_resp = client.post(
"/api/v1/auth/register",
json={"email": _unique_email(), "password": "correct-horse-1", "display_name": "T"},
)
assert no_token_resp.status_code == 400
with respx.mock:
respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock(
return_value=httpx.Response(200, json={"success": True})
)
with_token_resp = client.post(
"/api/v1/auth/register",
json={
"email": _unique_email(),
"password": "correct-horse-1",
"display_name": "T",
"turnstile_token": "good-token",
},
)
assert with_token_resp.status_code == 201
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from app.change_detection.text_diff import bounded_text_diff
def test_identical_text_has_zero_diff_ratio():
text = "Acme Corp builds electric trucks.\nWe are hiring engineers."
result = bounded_text_diff(text, text)
assert result.diff_ratio == 0.0
assert result.is_identical is True
def test_changed_text_has_nonzero_diff_ratio_and_captures_lines():
previous = "Acme Corp builds gasoline trucks.\nContact us for a quote."
current = "Acme Corp builds electric trucks.\nContact us for a quote."
result = bounded_text_diff(previous, current)
assert result.diff_ratio > 0.0
assert any("electric" in line for line in result.added_lines)
assert any("gasoline" in line for line in result.removed_lines)
def test_noise_only_changes_do_not_register_as_a_diff():
previous = "About us.\nUpdated: 2026-01-01 10:00\nWe build trucks."
current = "About us.\nUpdated: 2026-06-15 14:30\nWe build trucks."
result = bounded_text_diff(previous, current)
assert result.diff_ratio == 0.0
def test_diff_is_bounded_in_size():
previous = "\n".join(f"line {i} original" for i in range(200))
current = "\n".join(f"line {i} changed" for i in range(200))
result = bounded_text_diff(previous, current)
assert len(result.added_lines) <= 40
assert len(result.removed_lines) <= 40
+263
View File
@@ -0,0 +1,263 @@
"""Unban-request intake + admin IP-ban management (Phase 19). No dedicated
coverage existed for this endpoint group before - added alongside the
Mailpit removal, which changed submit_unban_request to notify every
is_admin=True account instead of a single fixed admin_notification_email."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.main import app
from app.repositories.ip_throttle_repository import IpThrottleRepository
from app.repositories.unban_request_repository import UnbanRequestRepository
from app.repositories.user_repository import UserRepository
def _unique_email() -> str:
return f"user-{uuid.uuid4().hex[:12]}@example.com"
def _unique_ip() -> str:
# Randomize all three trailing octets (same convention as
# test_ip_throttle_service.py) - a single-octet range only has ~250
# values, which collides often enough across a full suite run (birthday
# paradox) to cause real, intermittent failures between unrelated tests
# that happen to share ip_throttle_state rows.
return f"10.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}"
def _install_fake_smtp(monkeypatch) -> list[dict]:
sent: list[dict] = []
class FakeSmtp:
def __init__(self, host, port, timeout=10):
pass
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def starttls(self):
pass
def login(self, username, password):
pass
def sendmail(self, from_addr, to_addrs, message):
sent.append({"to": to_addrs, "message": message})
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FakeSmtp)
return sent
async def _register_admin_and_login(client: TestClient, db_session: AsyncSession) -> dict[str, str]:
email = _unique_email()
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Admin"},
)
user = await UserRepository(db_session).get_by_email(email)
user.is_admin = True
await db_session.commit()
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
def test_unban_request_requires_no_auth_and_returns_204(monkeypatch):
_install_fake_smtp(monkeypatch)
ip = _unique_ip()
with TestClient(app, client=(ip, 51234)) as c:
resp = c.post("/api/v1/unban-requests", json={"message": "please unban me"})
assert resp.status_code == 204
def test_unban_request_cooldown_rejects_second_request_within_24h(monkeypatch):
_install_fake_smtp(monkeypatch)
ip = _unique_ip()
with TestClient(app, client=(ip, 51234)) as c:
first = c.post("/api/v1/unban-requests", json={"message": "one"})
assert first.status_code == 204
second = c.post("/api/v1/unban-requests", json={"message": "two"})
assert second.status_code == 429
async def test_unban_request_notifies_every_admin_account_only(monkeypatch, db_session):
sent = _install_fake_smtp(monkeypatch)
admin_repo = UserRepository(db_session)
admin_one = await admin_repo.create(
email=_unique_email(),
password_hash="x",
display_name="Admin One",
timezone="UTC",
is_admin=True,
email_verified=True,
)
admin_two = await admin_repo.create(
email=_unique_email(),
password_hash="x",
display_name="Admin Two",
timezone="UTC",
is_admin=True,
email_verified=True,
)
not_admin = await admin_repo.create(
email=_unique_email(),
password_hash="x",
display_name="Not Admin",
timezone="UTC",
is_admin=False,
email_verified=True,
)
await db_session.commit()
ip = _unique_ip()
with TestClient(app, client=(ip, 51234)) as c:
resp = c.post("/api/v1/unban-requests", json={"message": "please unban me"})
assert resp.status_code == 204
# Other tests in the same run may have their own admin accounts (plus
# the fixed local-dev user, always admin) - assert membership, not an
# exact total count.
recipients = {msg["to"][0] for msg in sent}
assert admin_one.email in recipients
assert admin_two.email in recipients
assert not_admin.email not in recipients
async def test_admin_ip_ban_endpoints_work_for_an_admin(db_session: AsyncSession):
ip = _unique_ip()
with TestClient(app, client=(ip, 51234)) as c:
headers = await _register_admin_and_login(c, db_session)
bans_resp = c.get("/api/v1/admin/ip-bans", headers=headers)
assert bans_resp.status_code == 200
requests_resp = c.get("/api/v1/admin/unban-requests", headers=headers)
assert requests_resp.status_code == 200
def test_admin_ip_ban_endpoints_reject_non_admin(client: TestClient):
email = _unique_email()
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
resp = client.get("/api/v1/admin/ip-bans", headers=headers)
assert resp.status_code == 403
async def test_admin_can_delete_ip_ban_and_it_clears_throttle_state(db_session: AsyncSession):
banned_ip = _unique_ip()
await IpThrottleRepository(db_session).create_ban(banned_ip, "failed_login", datetime.now(UTC))
await db_session.commit()
admin_ip = _unique_ip()
with TestClient(app, client=(admin_ip, 51235)) as c:
headers = await _register_admin_and_login(c, db_session)
resp = c.delete(f"/api/v1/admin/ip-bans/{banned_ip}", headers=headers)
assert resp.status_code == 204
ban = await IpThrottleRepository(db_session).get_ban(banned_ip)
assert ban is None
async def test_admin_can_manually_ban_an_ip(db_session: AsyncSession):
target_ip = _unique_ip()
admin_ip = _unique_ip()
with TestClient(app, client=(admin_ip, 51236)) as c:
headers = await _register_admin_and_login(c, db_session)
resp = c.post("/api/v1/admin/ip-bans", json={"ip_address": target_ip}, headers=headers)
assert resp.status_code == 201
assert resp.json()["ip_address"] == target_ip
ban = await IpThrottleRepository(db_session).get_ban(target_ip)
assert ban is not None
async def test_admin_ban_ip_rejects_already_banned_ip(db_session: AsyncSession):
target_ip = _unique_ip()
await IpThrottleRepository(db_session).create_ban(target_ip, "failed_login", datetime.now(UTC))
await db_session.commit()
admin_ip = _unique_ip()
with TestClient(app, client=(admin_ip, 51237)) as c:
headers = await _register_admin_and_login(c, db_session)
resp = c.post("/api/v1/admin/ip-bans", json={"ip_address": target_ip}, headers=headers)
assert resp.status_code == 409
async def test_admin_ban_ip_rejects_malformed_address(db_session: AsyncSession):
admin_ip = _unique_ip()
with TestClient(app, client=(admin_ip, 51238)) as c:
headers = await _register_admin_and_login(c, db_session)
resp = c.post("/api/v1/admin/ip-bans", json={"ip_address": "not-an-ip"}, headers=headers)
assert resp.status_code == 422
async def test_admin_can_accept_an_unban_request_and_it_unbans_the_ip(db_session: AsyncSession):
requester_ip = _unique_ip()
await IpThrottleRepository(db_session).create_ban(
requester_ip, "failed_login", datetime.now(UTC)
)
await db_session.commit()
with TestClient(app, client=(requester_ip, 51240)) as c:
c.post("/api/v1/unban-requests", json={"message": "please unban me"})
request = await UnbanRequestRepository(db_session).most_recent_for_ip(requester_ip)
assert request is not None
admin_ip = _unique_ip()
with TestClient(app, client=(admin_ip, 51241)) as c:
headers = await _register_admin_and_login(c, db_session)
resp = c.post(f"/api/v1/admin/unban-requests/{request.id}/accept", headers=headers)
assert resp.status_code == 204
assert await IpThrottleRepository(db_session).get_ban(requester_ip) is None
assert await UnbanRequestRepository(db_session).get(request.id) is None
async def test_admin_can_reject_an_unban_request_and_the_ip_stays_banned(db_session: AsyncSession):
requester_ip = _unique_ip()
await IpThrottleRepository(db_session).create_ban(
requester_ip, "failed_login", datetime.now(UTC)
)
await db_session.commit()
with TestClient(app, client=(requester_ip, 51242)) as c:
c.post("/api/v1/unban-requests", json={"message": "please unban me"})
request = await UnbanRequestRepository(db_session).most_recent_for_ip(requester_ip)
assert request is not None
admin_ip = _unique_ip()
with TestClient(app, client=(admin_ip, 51243)) as c:
headers = await _register_admin_and_login(c, db_session)
resp = c.delete(f"/api/v1/admin/unban-requests/{request.id}", headers=headers)
assert resp.status_code == 204
assert await IpThrottleRepository(db_session).get_ban(requester_ip) is not None
assert await UnbanRequestRepository(db_session).get(request.id) is None
async def test_admin_accept_unban_request_404s_for_unknown_id(db_session: AsyncSession):
admin_ip = _unique_ip()
with TestClient(app, client=(admin_ip, 51244)) as c:
headers = await _register_admin_and_login(c, db_session)
resp = c.post(f"/api/v1/admin/unban-requests/{uuid.uuid4()}/accept", headers=headers)
assert resp.status_code == 404
+289
View File
@@ -0,0 +1,289 @@
"""Per-user API keys: encryption roundtrip, repository/service behavior,
endpoint auth/ownership, and one end-to-end check that a user's own key is
actually used (not just stored) for a real provider call."""
from __future__ import annotations
import uuid
import httpx
import pytest
import respx
from fastapi.testclient import TestClient
from app.core.config import get_settings
from app.core.crypto import decrypt_secret, encrypt_secret
from app.main import app
from app.models.enums import ApiKeyProvider
from app.repositories.user_api_key_repository import UserApiKeyRepository
from app.repositories.user_repository import UserRepository
from app.services import user_api_key_service
def _unique_email() -> str:
return f"user-{uuid.uuid4().hex[:12]}@example.com"
def _register_and_login(client: TestClient) -> dict[str, str]:
email = _unique_email()
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
# --- Encryption --------------------------------------------------------
def test_encrypt_decrypt_roundtrip():
settings = get_settings()
ciphertext = encrypt_secret("sk-real-secret-value", settings)
assert ciphertext != "sk-real-secret-value"
assert decrypt_secret(ciphertext, settings) == "sk-real-secret-value"
def test_decrypt_with_wrong_key_raises():
settings = get_settings()
other_key_settings = settings.model_copy(
update={"api_key_encryption_secret": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}
)
ciphertext = encrypt_secret("sk-real-secret-value", settings)
with pytest.raises(ValueError):
decrypt_secret(ciphertext, other_key_settings)
# --- Repository ----------------------------------------------------------
async def test_repository_upsert_then_get_then_delete(db_session):
repo = UserApiKeyRepository(db_session)
user_id = uuid.uuid4()
await repo.upsert(user_id, ApiKeyProvider.ANTHROPIC, "encrypted-1")
row = await repo.get(user_id, ApiKeyProvider.ANTHROPIC)
assert row is not None
assert row.encrypted_key == "encrypted-1"
await repo.upsert(user_id, ApiKeyProvider.ANTHROPIC, "encrypted-2")
row = await repo.get(user_id, ApiKeyProvider.ANTHROPIC)
assert row.encrypted_key == "encrypted-2" # updated in place, not duplicated
await repo.delete(user_id, ApiKeyProvider.ANTHROPIC)
assert await repo.get(user_id, ApiKeyProvider.ANTHROPIC) is None
# --- Service ---------------------------------------------------------------
async def test_list_status_shows_all_four_providers_unconfigured_by_default(db_session):
settings = get_settings()
statuses = await user_api_key_service.list_status(db_session, uuid.uuid4(), settings)
assert {s["provider"] for s in statuses} == {
"anthropic",
"brave_search",
"ninjapear",
"uspto",
}
assert all(s["configured"] is False for s in statuses)
assert all(s["value"] is None for s in statuses)
uspto = next(s for s in statuses if s["provider"] == "uspto")
assert uspto["free"] is True
assert uspto["requires_government_id"] is True
async def test_set_key_then_list_status_shows_it_configured(db_session):
settings = get_settings()
user_id = uuid.uuid4()
await user_api_key_service.set_key(
db_session,
user_id,
ApiKeyProvider.ANTHROPIC,
"sk-my-real-key",
settings,
client_ip="10.0.0.1",
)
statuses = await user_api_key_service.list_status(db_session, user_id, settings)
anthropic = next(s for s in statuses if s["provider"] == "anthropic")
assert anthropic["configured"] is True
assert anthropic["value"] == "sk-my-real-key"
async def test_set_blank_key_clears_a_previously_set_one(db_session):
settings = get_settings()
user_id = uuid.uuid4()
await user_api_key_service.set_key(
db_session,
user_id,
ApiKeyProvider.ANTHROPIC,
"sk-my-real-key",
settings,
client_ip="10.0.0.1",
)
await user_api_key_service.set_key(
db_session, user_id, ApiKeyProvider.ANTHROPIC, " ", settings, client_ip="10.0.0.1"
)
statuses = await user_api_key_service.list_status(db_session, user_id, settings)
anthropic = next(s for s in statuses if s["provider"] == "anthropic")
assert anthropic["configured"] is False
assert anthropic["value"] is None
async def test_get_effective_settings_falls_back_to_global_when_unset(db_session):
settings = get_settings().model_copy(update={"anthropic_api_key": "global-key"})
effective = await user_api_key_service.get_effective_settings(
db_session, uuid.uuid4(), settings
)
assert effective.anthropic_api_key == "global-key"
async def test_get_effective_settings_overrides_only_the_providers_the_user_set(db_session):
settings = get_settings().model_copy(
update={"anthropic_api_key": "global-anthropic", "brave_search_api_key": "global-brave"}
)
user_id = uuid.uuid4()
await user_api_key_service.set_key(
db_session,
user_id,
ApiKeyProvider.ANTHROPIC,
"my-own-anthropic-key",
settings,
client_ip="10.0.0.1",
)
effective = await user_api_key_service.get_effective_settings(db_session, user_id, settings)
assert effective.anthropic_api_key == "my-own-anthropic-key"
assert effective.brave_search_api_key == "global-brave" # untouched, no override set
async def test_list_status_never_fetches_ninjapear_credits_itself(db_session):
"""list_status must never make its own live NinjaPear call - the
frontend sources that number from /system/status's already-fetched
ninjapear_credit_balance instead (see the Settings page's System
configuration box), so credits is always None from this endpoint
regardless of whether a key is configured."""
settings = get_settings()
user_id = uuid.uuid4()
await user_api_key_service.set_key(
db_session,
user_id,
ApiKeyProvider.NINJAPEAR,
"my-ninjapear-key",
settings,
client_ip="10.0.0.1",
)
with respx.mock:
# No mock registered for nubela.co - respx raises if anything tries
# to call it, proving list_status makes no such request.
statuses = await user_api_key_service.list_status(db_session, user_id, settings)
ninjapear = next(s for s in statuses if s["provider"] == "ninjapear")
assert ninjapear["configured"] is True
assert ninjapear["credits"] is None
# --- Endpoints ---------------------------------------------------------
def test_list_user_api_keys_requires_auth(client: TestClient):
resp = client.get("/api/v1/user-api-keys")
assert resp.status_code == 401
def test_list_and_set_user_api_key_round_trip(client: TestClient):
headers = _register_and_login(client)
initial = client.get("/api/v1/user-api-keys", headers=headers)
assert initial.status_code == 200
assert all(not s["configured"] for s in initial.json())
set_resp = client.put(
"/api/v1/user-api-keys/anthropic", json={"key": "sk-set-via-api"}, headers=headers
)
assert set_resp.status_code == 200
assert set_resp.json()["configured"] is True
assert set_resp.json()["value"] == "sk-set-via-api"
after = client.get("/api/v1/user-api-keys", headers=headers)
anthropic = next(s for s in after.json() if s["provider"] == "anthropic")
assert anthropic["configured"] is True
assert anthropic["value"] == "sk-set-via-api"
def test_updating_your_own_api_key_is_logged_to_account_activity(client: TestClient):
headers = _register_and_login(client)
client.put("/api/v1/user-api-keys/anthropic", json={"key": "sk-set-via-api"}, headers=headers)
events = client.get("/api/v1/auth/security-events", headers=headers).json()
assert any(e["event_type"] == "api_key_updated" for e in events)
def test_set_user_api_key_rejects_unknown_provider(client: TestClient):
headers = _register_and_login(client)
resp = client.put(
"/api/v1/user-api-keys/not-a-real-provider", json={"key": "x"}, headers=headers
)
assert resp.status_code == 422
async def test_two_users_keys_are_fully_isolated(client: TestClient, db_session):
headers_a = _register_and_login(client)
headers_b = _register_and_login(client)
client.put("/api/v1/user-api-keys/anthropic", json={"key": "a-key"}, headers=headers_a)
b_keys = client.get("/api/v1/user-api-keys", headers=headers_b).json()
anthropic_b = next(s for s in b_keys if s["provider"] == "anthropic")
assert anthropic_b["configured"] is False
assert anthropic_b["value"] is None
# --- Provider wiring: the user's own key is actually used ------------------
async def test_discover_endpoint_uses_the_callers_own_anthropic_and_brave_keys(
client: TestClient, db_session, monkeypatch
):
"""End-to-end proof this isn't just stored and ignored - the actual
outbound Brave Search call for this request carries the user's own
key, not the server's global one."""
monkeypatch.setattr("app.core.config.Settings.search_provider", "brave", raising=False)
headers = _register_and_login(client)
user = await UserRepository(db_session).get_by_email(
client.get("/api/v1/auth/me", headers=headers).json()["email"]
)
settings = get_settings()
await user_api_key_service.set_key(
db_session,
user.id,
ApiKeyProvider.BRAVE_SEARCH,
"my-own-brave-key",
settings,
client_ip="10.0.0.1",
)
seen_auth_tokens: list[str] = []
def _capture(request: httpx.Request) -> httpx.Response:
seen_auth_tokens.append(request.headers.get("X-Subscription-Token", ""))
return httpx.Response(200, json={"web": {"results": []}})
test_settings = get_settings().model_copy(update={"search_provider": "brave"})
app.dependency_overrides[get_settings] = lambda: test_settings
try:
with respx.mock:
respx.get(url__regex=r"https://api\.search\.brave\.com/.*").mock(side_effect=_capture)
client.post("/api/v1/companies/discover", json={"name": "Acme Corp"}, headers=headers)
finally:
app.dependency_overrides.pop(get_settings, None)
assert "my-own-brave-key" in seen_auth_tokens
+118
View File
@@ -0,0 +1,118 @@
"""Per-account known-IP ledger: pure data capture on every recorded sign-in
(real login and the local-dev bypass), one row per distinct (user, ip) pair,
touched rather than duplicated on repeat visits from the same IP. Nothing
currently reads this data - it's the foundation a later "new IP" security
feature would query against."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from fastapi.testclient import TestClient
from app.db.base import ensure_aware_utc
from app.main import app
from app.models.user import LOCAL_DEV_USER_ID
from app.repositories.user_known_ip_repository import UserKnownIpRepository
from app.repositories.user_repository import UserRepository
def _unique_email() -> str:
return f"user-{uuid.uuid4().hex[:12]}@example.com"
def _unique_ip() -> str:
return f"10.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}"
# --- Repository --------------------------------------------------------
async def test_record_login_creates_a_row_for_a_new_ip_and_reports_it_as_new(db_session):
repo = UserKnownIpRepository(db_session)
user_id = uuid.uuid4()
now = datetime.now(UTC)
is_new = await repo.record_login(user_id, "203.0.113.5", now)
assert is_new is True
row = await repo.get(user_id, "203.0.113.5")
assert row is not None
assert ensure_aware_utc(row.first_seen_at) == now
assert ensure_aware_utc(row.last_seen_at) == now
async def test_record_login_touches_last_seen_instead_of_duplicating(db_session):
repo = UserKnownIpRepository(db_session)
user_id = uuid.uuid4()
first_seen = datetime.now(UTC) - timedelta(days=1)
second_visit = datetime.now(UTC)
await repo.record_login(user_id, "203.0.113.6", first_seen)
is_new = await repo.record_login(user_id, "203.0.113.6", second_visit)
assert is_new is False
rows = await repo.list_for_user(user_id)
assert len(rows) == 1
assert ensure_aware_utc(rows[0].first_seen_at) == first_seen
assert ensure_aware_utc(rows[0].last_seen_at) == second_visit
async def test_a_second_distinct_ip_creates_a_second_row(db_session):
repo = UserKnownIpRepository(db_session)
user_id = uuid.uuid4()
now = datetime.now(UTC)
await repo.record_login(user_id, "203.0.113.7", now)
await repo.record_login(user_id, "203.0.113.8", now)
rows = await repo.list_for_user(user_id)
assert {r.ip_address for r in rows} == {"203.0.113.7", "203.0.113.8"}
# --- Wired into real sign-in flows --------------------------------------
async def test_real_login_records_the_client_ip(db_session):
ip = _unique_ip()
email = _unique_email()
with TestClient(app, client=(ip, 51234)) as c:
c.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
)
login_resp = c.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
)
assert login_resp.status_code == 200
user = await UserRepository(db_session).get_by_email(email)
rows = await UserKnownIpRepository(db_session).list_for_user(user.id)
assert [r.ip_address for r in rows] == [ip]
async def test_repeat_login_from_the_same_ip_does_not_duplicate_the_row(db_session):
ip = _unique_ip()
email = _unique_email()
with TestClient(app, client=(ip, 51235)) as c:
c.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
)
c.post("/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"})
c.post("/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"})
user = await UserRepository(db_session).get_by_email(email)
rows = await UserKnownIpRepository(db_session).list_for_user(user.id)
assert len(rows) == 1
async def test_local_dev_sign_in_records_the_known_ip(local_mode_client, db_session):
# local_mode_client's fixed loopback peer (see conftest.py) should show
# up as a known IP for the local-dev account after this request.
resp = local_mode_client.get("/api/v1/auth/me")
assert resp.status_code == 200
rows = await UserKnownIpRepository(db_session).list_for_user(LOCAL_DEV_USER_ID)
assert "127.0.0.1" in {r.ip_address for r in rows}