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:
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"] == {}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user