feat(auth): add local user signup, email verification, and Stripe billing

- Add LocalUser model with bcrypt password hashing, email verification
  tokens, and password reset tokens
- Add ALLOW_LOCAL_SIGNUP config flag (requires SMTP to be configured)
- Add Stripe billing config fields (STRIPE_SECRET_KEY, etc.)
- Add stripe_customer_id to UserProfile and stripe_price_id_monthly/
  stripe_price_id_yearly to SubscriptionPlan
- Create migration 018_add_local_users_and_billing
- Add app/utils/local_auth.py: hash_password, verify_password,
  generate_token, is_token_expired, send_verification_email,
  send_password_reset_email, build_session_user
- Add app/api/local_auth.py: signup, email verification, password reset
  endpoints plus signup/verify-email-sent/reset-password page routes
- Add app/api/billing.py: Stripe Checkout, Customer Portal, and webhook
  endpoints; syncs subscription tier from webhook events
- Update auth() to check LocalUser table before admin credentials fallback
- Update login() to pass allow_signup context variable to template
- Add signup.html, verify_email_sent.html, password_reset_form.html,
  billing_success.html templates (Alpine.js, Tailwind, WCAG 2.1 AA)
- Update login.html to show 'Create account' link when signup enabled
- Update pricing.html CTA buttons to use Stripe Checkout for paid tiers
- Add docs/BillingSetup.md with setup guide, webhook config, compliance
- Add tests/test_local_auth.py (42 tests) and tests/test_billing.py
  (31 tests); all 106 tests in the modified test suite pass
- Add stripe>=7.0.0,<15.0.0 to requirements.txt

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-07 13:15:15 +00:00
parent e0de0fd6fb
commit 52e3852129
20 changed files with 2844 additions and 13 deletions
+2
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter
from app.api.admin_users import router as admin_users_router from app.api.admin_users import router as admin_users_router
from app.api.azure import router as azure_router from app.api.azure import router as azure_router
from app.api.billing import router as billing_router
from app.api.database import router as database_router from app.api.database import router as database_router
from app.api.diagnostic import router as diagnostic_router from app.api.diagnostic import router as diagnostic_router
from app.api.dropbox import router as dropbox_router from app.api.dropbox import router as dropbox_router
@@ -62,3 +63,4 @@ router.include_router(database_router)
router.include_router(subscriptions_router) router.include_router(subscriptions_router)
router.include_router(plans_router) router.include_router(plans_router)
router.include_router(onboarding_router) router.include_router(onboarding_router)
router.include_router(billing_router)
+413
View File
@@ -0,0 +1,413 @@
"""Stripe billing integration for DocuElevate.
Provides three endpoints:
- POST /api/billing/create-checkout-session — starts Stripe Checkout for a plan upgrade
- POST /api/billing/create-portal-session — opens Stripe Customer Portal (manage/cancel)
- POST /api/billing/webhook — handles Stripe webhook events
- GET /api/billing/success — success landing page after checkout
Stripe Python SDK license: MIT (compatible with this project's Apache 2.0 license).
GDPR: Stripe acts as a data processor under a Data Processing Agreement (DPA).
Stripe is SOC 2 Type II certified and supports EU data residency.
SOC2: Stripe is SOC 2 Type II certified.
EU VAT: Configure Stripe Tax in the Stripe Dashboard for automatic VAT collection.
"""
import json
import logging
import pathlib
from datetime import datetime, timezone
from typing import Any
import stripe
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.models import SubscriptionPlan, UserProfile
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/billing", tags=["billing"])
_templates_dir = pathlib.Path(__file__).parents[2] / "frontend" / "templates"
_templates = Jinja2Templates(directory=str(_templates_dir))
def _get_stripe() -> stripe.StripeClient | None:
"""Return a configured Stripe client, or None when not configured."""
if not settings.stripe_secret_key:
return None
return stripe.StripeClient(settings.stripe_secret_key)
def _get_or_create_stripe_customer(
client: stripe.StripeClient,
db: Session,
owner_id: str,
email: str | None,
name: str | None,
) -> str:
"""Return the Stripe customer_id for *owner_id*, creating one if needed.
Args:
client: Configured Stripe client.
db: Database session.
owner_id: Stable user identifier.
email: User's email for the Stripe customer record.
name: User's display name for the Stripe customer record.
Returns:
The Stripe customer ID string.
"""
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
if profile and profile.stripe_customer_id:
return profile.stripe_customer_id
customer = client.customers.create(
params={
"email": email or "",
"name": name or "",
"metadata": {"docuelevate_user_id": owner_id},
}
)
if profile:
profile.stripe_customer_id = customer.id
db.commit()
return customer.id
class CheckoutSessionBody(BaseModel):
"""Request body for creating a Stripe Checkout session."""
plan_id: str
billing_cycle: str = "monthly" # "monthly" | "yearly"
class PortalSessionBody(BaseModel):
"""Request body for creating a Stripe Customer Portal session."""
return_url: str | None = None
@router.post("/create-checkout-session", summary="Create a Stripe Checkout session for a plan upgrade")
@require_login
async def create_checkout_session(
request: Request,
body: CheckoutSessionBody,
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""Create a Stripe Checkout session.
The client should redirect the user to the returned ``checkout_url``.
Raises:
503: Stripe is not configured.
404: Plan not found or has no Stripe price configured.
"""
client = _get_stripe()
if not client:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Billing is not configured.")
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == body.plan_id).first()
if plan is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan {body.plan_id!r} not found.")
price_id = plan.stripe_price_id_yearly if body.billing_cycle == "yearly" else plan.stripe_price_id_monthly
if not price_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=(
f"Stripe price ID not configured for plan {body.plan_id!r} ({body.billing_cycle}). "
"Please set it in the Admin Plan Designer."
),
)
user = request.session.get("user") or {}
owner_id = get_current_owner_id(request) or user.get("email") or ""
email = user.get("email")
name = user.get("name")
customer_id = _get_or_create_stripe_customer(client, db, owner_id, email, name)
base = str(request.base_url).rstrip("/")
success_url = settings.stripe_success_url or f"{base}/api/billing/success"
cancel_url = settings.stripe_cancel_url or f"{base}/pricing"
trial_days = plan.trial_days if plan.trial_days > 0 else None
session_params: dict[str, Any] = {
"customer": customer_id,
"mode": "subscription",
"line_items": [{"price": price_id, "quantity": 1}],
"success_url": success_url + "?session_id={CHECKOUT_SESSION_ID}",
"cancel_url": cancel_url,
"subscription_data": {
"metadata": {
"docuelevate_user_id": owner_id,
"plan_id": body.plan_id,
"billing_cycle": body.billing_cycle,
},
},
"metadata": {"docuelevate_user_id": owner_id, "plan_id": body.plan_id},
"allow_promotion_codes": True,
"billing_address_collection": "auto",
"tax_id_collection": {"enabled": True},
"automatic_tax": {"enabled": True},
}
if trial_days:
session_params["subscription_data"]["trial_period_days"] = trial_days
checkout_session = client.checkout.sessions.create(params=session_params)
logger.info(
"Created Stripe checkout session %s for user %s plan %s",
checkout_session.id,
owner_id,
body.plan_id,
)
return {"checkout_url": checkout_session.url, "session_id": checkout_session.id}
@router.post("/create-portal-session", summary="Create a Stripe Customer Portal session")
@require_login
async def create_portal_session(
request: Request,
body: PortalSessionBody,
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""Create a Stripe Customer Portal session for subscription self-management.
Raises:
503: Stripe not configured.
404: No Stripe customer found for this user.
"""
client = _get_stripe()
if not client:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Billing is not configured.")
user = request.session.get("user") or {}
owner_id = get_current_owner_id(request) or user.get("email") or ""
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
if not profile or not profile.stripe_customer_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No billing account found. Please subscribe to a plan first.",
)
base = str(request.base_url).rstrip("/")
return_url = body.return_url or f"{base}/subscription"
portal = client.billing_portal.sessions.create(
params={
"customer": profile.stripe_customer_id,
"return_url": return_url,
}
)
logger.info("Created Stripe portal session for user %s", owner_id)
return {"portal_url": portal.url}
@router.post("/webhook", include_in_schema=False)
async def stripe_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, str]:
"""Handle Stripe webhook events.
Syncs subscription status to UserProfile.subscription_tier.
Events handled:
- ``checkout.session.completed`` — activate subscription after payment
- ``customer.subscription.updated`` — sync tier change
- ``customer.subscription.deleted`` — downgrade to free on cancellation
- ``invoice.payment_failed`` — log failed payment
"""
if not settings.stripe_secret_key:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Billing not configured.")
payload = await request.body()
sig_header = request.headers.get("stripe-signature", "")
try:
if settings.stripe_webhook_secret:
event = stripe.Webhook.construct_event(payload, sig_header, settings.stripe_webhook_secret)
else:
event = stripe.Event.construct_from(json.loads(payload), stripe.api_key)
except stripe.SignatureVerificationError:
logger.warning("[SECURITY] Stripe webhook signature verification failed")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid webhook signature.")
except Exception as exc:
logger.warning("Failed to parse Stripe webhook: %s", exc)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid webhook payload.")
_handle_stripe_event(db, event)
return {"status": "ok"}
@router.get("/success", include_in_schema=False)
@require_login
async def billing_success(request: Request) -> Any:
"""Show a success page after a completed Stripe Checkout."""
return _templates.TemplateResponse("billing_success.html", {"request": request})
def _handle_stripe_event(db: Session, event: Any) -> None:
"""Dispatch Stripe event to the appropriate handler.
Args:
db: Database session.
event: Parsed Stripe event object.
"""
etype = event.get("type", "") if isinstance(event, dict) else getattr(event, "type", "")
data_obj = (
event.get("data", {}).get("object", {})
if isinstance(event, dict)
else getattr(getattr(event, "data", None), "object", {})
)
if etype == "checkout.session.completed":
_on_checkout_completed(db, data_obj)
elif etype == "customer.subscription.updated":
_on_subscription_updated(db, data_obj)
elif etype == "customer.subscription.deleted":
_on_subscription_deleted(db, data_obj)
elif etype == "invoice.payment_failed":
customer_id = data_obj.get("customer", "") if isinstance(data_obj, dict) else getattr(data_obj, "customer", "")
logger.warning("Stripe invoice payment failed for customer %s", customer_id)
else:
logger.debug("Unhandled Stripe event type: %s", etype)
def _resolve_user_id_from_customer(db: Session, customer_id: str) -> str | None:
"""Look up the DocuElevate user_id for a Stripe customer_id.
Args:
db: Database session.
customer_id: Stripe customer ID.
Returns:
The matching ``UserProfile.user_id``, or ``None`` if not found.
"""
profile = db.query(UserProfile).filter(UserProfile.stripe_customer_id == customer_id).first()
return profile.user_id if profile else None
def _resolve_plan_id_from_price(db: Session, price_id: str) -> str | None:
"""Map a Stripe price_id to a DocuElevate plan_id via SubscriptionPlan.
Args:
db: Database session.
price_id: Stripe price ID.
Returns:
The matching ``SubscriptionPlan.plan_id``, or ``None`` if not found.
"""
plan = (
db.query(SubscriptionPlan)
.filter(
(SubscriptionPlan.stripe_price_id_monthly == price_id)
| (SubscriptionPlan.stripe_price_id_yearly == price_id)
)
.first()
)
return plan.plan_id if plan else None
def _on_checkout_completed(db: Session, data: Any) -> None:
"""Activate a subscription after a successful checkout.
Args:
db: Database session.
data: Stripe ``checkout.session`` object.
"""
meta = data.get("metadata") or {} if isinstance(data, dict) else getattr(data, "metadata", {}) or {}
user_id = meta.get("docuelevate_user_id") if isinstance(meta, dict) else getattr(meta, "docuelevate_user_id", None)
plan_id = meta.get("plan_id") if isinstance(meta, dict) else getattr(meta, "plan_id", None)
billing_cycle = (
meta.get("billing_cycle", "monthly") if isinstance(meta, dict) else getattr(meta, "billing_cycle", "monthly")
)
if not user_id:
return
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if profile and plan_id:
profile.subscription_tier = plan_id
profile.subscription_billing_cycle = billing_cycle
profile.subscription_period_start = datetime.now(tz=timezone.utc)
customer_id = data.get("customer", "") if isinstance(data, dict) else getattr(data, "customer", "")
if customer_id:
profile.stripe_customer_id = customer_id
db.commit()
logger.info("Activated plan %s/%s for user %s after checkout", plan_id, billing_cycle, user_id)
def _on_subscription_updated(db: Session, data: Any) -> None:
"""Sync tier change when a subscription is updated.
Args:
db: Database session.
data: Stripe ``customer.subscription`` object.
"""
customer_id = data.get("customer", "") if isinstance(data, dict) else getattr(data, "customer", "")
user_id = _resolve_user_id_from_customer(db, customer_id)
if not user_id:
return
items_data = data.get("items") or {} if isinstance(data, dict) else getattr(data, "items", None) or {}
items = items_data.get("data") or [] if isinstance(items_data, dict) else getattr(items_data, "data", []) or []
if not items:
return
first_item = items[0]
price_obj = (
first_item.get("price") or {} if isinstance(first_item, dict) else getattr(first_item, "price", {}) or {}
)
price_id = price_obj.get("id") if isinstance(price_obj, dict) else getattr(price_obj, "id", None)
if not price_id:
return
plan_id = _resolve_plan_id_from_price(db, price_id)
if not plan_id:
logger.warning("Unknown Stripe price_id %s on subscription.updated", price_id)
return
recurring = (
price_obj.get("recurring", {}) if isinstance(price_obj, dict) else getattr(price_obj, "recurring", {}) or {}
)
interval = (
recurring.get("interval", "month") if isinstance(recurring, dict) else getattr(recurring, "interval", "month")
)
billing_cycle = "yearly" if interval == "year" else "monthly"
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if profile:
profile.subscription_tier = plan_id
profile.subscription_billing_cycle = billing_cycle
db.commit()
logger.info("Updated subscription to %s/%s for user %s", plan_id, billing_cycle, user_id)
def _on_subscription_deleted(db: Session, data: Any) -> None:
"""Downgrade user to free tier after subscription cancellation.
Args:
db: Database session.
data: Stripe ``customer.subscription`` object.
"""
customer_id = data.get("customer", "") if isinstance(data, dict) else getattr(data, "customer", "")
user_id = _resolve_user_id_from_customer(db, customer_id)
if not user_id:
return
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if profile:
profile.subscription_tier = "free"
profile.subscription_billing_cycle = "monthly"
db.commit()
logger.info("Downgraded user %s to free tier after subscription cancellation", user_id)
+315
View File
@@ -0,0 +1,315 @@
"""Local user authentication API — signup, email verification, password reset.
Provides the REST endpoints and page routes for the self-registration flow:
- GET /signup — signup page (HTML)
- POST /api/auth/signup — create account + send verification email
- GET /verify-email — activate account from email link (redirect)
- GET /verify-email-sent — confirmation landing page (HTML)
- POST /api/auth/resend-verification — re-send verification email
- POST /api/auth/request-password-reset — start password reset
- POST /api/auth/reset-password — set new password using token
- GET /reset-password — password reset form page (HTML)
"""
import logging
import pathlib
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from starlette.responses import RedirectResponse
from app.config import settings
from app.database import get_db
from app.models import LocalUser, UserProfile
from app.utils.local_auth import (
build_session_user,
generate_token,
hash_password,
is_token_expired,
send_password_reset_email,
send_verification_email,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["local-auth"])
_templates_dir = pathlib.Path(__file__).parents[2] / "frontend" / "templates"
templates = Jinja2Templates(directory=str(_templates_dir))
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class SignupBody(BaseModel):
"""Body for the signup endpoint."""
email: str = Field(..., max_length=255)
username: str = Field(..., min_length=3, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$")
display_name: str | None = Field(default=None, max_length=255)
password: str = Field(..., min_length=8, max_length=128)
password_confirm: str
class ResendVerificationBody(BaseModel):
"""Body for the resend-verification endpoint."""
email: str
class PasswordResetRequestBody(BaseModel):
"""Body for the request-password-reset endpoint."""
email: str
class PasswordResetBody(BaseModel):
"""Body for the reset-password endpoint."""
token: str
new_password: str = Field(..., min_length=8, max_length=128)
new_password_confirm: str
# ---------------------------------------------------------------------------
# Page routes (return HTML)
# ---------------------------------------------------------------------------
@router.get("/signup", include_in_schema=False)
async def signup_page(request: Request) -> Any:
"""Render the signup page, or redirect to login when signup is disabled."""
if not settings.allow_local_signup:
return RedirectResponse(url="/login?error=Registration+is+not+enabled", status_code=302)
return templates.TemplateResponse(
"signup.html",
{
"request": request,
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
},
)
@router.get("/verify-email-sent", include_in_schema=False)
async def verify_email_sent_page(request: Request) -> Any:
"""Render the verify-email-sent confirmation page."""
return templates.TemplateResponse("verify_email_sent.html", {"request": request})
@router.get("/reset-password", include_in_schema=False)
async def reset_password_page(request: Request) -> Any:
"""Render the password reset form page."""
token = request.query_params.get("token", "")
return templates.TemplateResponse(
"password_reset_form.html",
{
"request": request,
"token": token,
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
},
)
# ---------------------------------------------------------------------------
# API endpoints (return JSON or redirect)
# ---------------------------------------------------------------------------
@router.post("/api/auth/signup", status_code=status.HTTP_201_CREATED)
async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str, str]:
"""Create a new local user account and send a verification email.
The account is inactive until the user clicks the email link.
Raises:
403: Local signup is disabled.
503: SMTP is not configured.
422: Passwords do not match.
409: Email or username already registered.
"""
if not settings.allow_local_signup:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Registration is not enabled.")
if not settings.email_host:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Email (SMTP) must be configured before local signup can be enabled.",
)
if body.password != body.password_confirm:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Passwords do not match.")
if db.query(LocalUser).filter(LocalUser.email == body.email).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered.")
if db.query(LocalUser).filter(LocalUser.username == body.username).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.")
token = generate_token()
user = LocalUser(
email=body.email,
username=body.username,
display_name=body.display_name,
hashed_password=hash_password(body.password),
is_active=False,
email_verification_token=token,
email_verification_sent_at=datetime.now(tz=timezone.utc),
)
db.add(user)
profile = UserProfile(
user_id=body.email,
display_name=body.display_name or body.username,
)
db.add(profile)
try:
db.commit()
except Exception:
db.rollback()
raise
base_url = str(request.base_url).rstrip("/")
try:
send_verification_email(body.email, body.username, token, base_url)
except Exception as exc:
# Clean up orphan records — don't leave an unverifiable account
try:
db.delete(user)
db.delete(profile)
db.commit()
except Exception:
db.rollback()
logger.warning("Signup email failed for %s: %s", body.email, exc)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=("Failed to send verification email. Please check that SMTP is correctly configured and try again."),
) from exc
logger.info("New local user registered: %s", body.email)
return {"message": "Verification email sent. Please check your inbox."}
@router.get("/verify-email", include_in_schema=False)
async def verify_email(request: Request, db: DbSession) -> Any:
"""Activate a local user account from the email verification link.
Redirects to the login page on failure, or to onboarding/upload on success.
"""
token = request.query_params.get("token", "")
user = db.query(LocalUser).filter(LocalUser.email_verification_token == token).first()
if not user:
return RedirectResponse(
url="/login?error=Invalid+or+expired+verification+link",
status_code=302,
)
if is_token_expired(user.email_verification_sent_at):
return RedirectResponse(
url="/login?error=Verification+link+has+expired.+Please+request+a+new+one",
status_code=302,
)
user.is_active = True
user.email_verification_token = None
user.email_verification_sent_at = None
# Ensure profile exists
if not db.query(UserProfile).filter(UserProfile.user_id == user.email).first():
db.add(UserProfile(user_id=user.email, display_name=user.display_name or user.username))
db.commit()
request.session["user"] = build_session_user(user)
logger.info("[SECURITY] EMAIL_VERIFIED user=%s", user.email)
profile = db.query(UserProfile).filter(UserProfile.user_id == user.email).first()
if profile and not profile.onboarding_completed:
post_onboarding = request.session.pop("redirect_after_login", "/upload")
request.session["post_onboarding_redirect"] = post_onboarding
return RedirectResponse(url="/onboarding", status_code=302)
return RedirectResponse(url="/upload", status_code=302)
@router.post("/api/auth/resend-verification")
async def resend_verification(request: Request, body: ResendVerificationBody, db: DbSession) -> dict[str, str]:
"""Re-send the verification email for a pending account.
Always returns 200 to avoid leaking whether an email is registered.
"""
user = db.query(LocalUser).filter(LocalUser.email == body.email).first()
if not user or user.is_active:
return {"message": "Verification email resent if account exists."}
token = generate_token()
user.email_verification_token = token
user.email_verification_sent_at = datetime.now(tz=timezone.utc)
db.commit()
base_url = str(request.base_url).rstrip("/")
try:
send_verification_email(user.email, user.username, token, base_url)
except Exception as exc:
logger.warning("Failed to resend verification email to %s: %s", user.email, exc)
return {"message": "Verification email resent if account exists."}
@router.post("/api/auth/request-password-reset")
async def request_password_reset(request: Request, body: PasswordResetRequestBody, db: DbSession) -> dict[str, str]:
"""Send a password reset email.
Always returns 200 to avoid leaking whether an email is registered.
"""
user = db.query(LocalUser).filter(LocalUser.email == body.email).first()
if not user:
return {"message": "Password reset email sent if account exists."}
token = generate_token()
user.password_reset_token = token
user.password_reset_sent_at = datetime.now(tz=timezone.utc)
db.commit()
base_url = str(request.base_url).rstrip("/")
try:
send_password_reset_email(user.email, user.username, token, base_url)
except Exception as exc:
logger.warning("Failed to send password reset email to %s: %s", user.email, exc)
return {"message": "Password reset email sent if account exists."}
@router.post("/api/auth/reset-password")
async def reset_password(body: PasswordResetBody, db: DbSession) -> dict[str, str]:
"""Set a new password using a valid reset token.
Raises:
400: Token is invalid or expired.
422: Passwords do not match.
"""
user = db.query(LocalUser).filter(LocalUser.password_reset_token == body.token).first()
if not user or is_token_expired(user.password_reset_sent_at):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired reset token.",
)
if body.new_password != body.new_password_confirm:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Passwords do not match.",
)
user.hashed_password = hash_password(body.new_password)
user.password_reset_token = None
user.password_reset_sent_at = None
db.commit()
logger.info("[SECURITY] PASSWORD_RESET_SUCCESS user=%s", user.email)
return {"message": "Password updated successfully."}
+39 -8
View File
@@ -72,7 +72,7 @@ def get_gravatar_url(email):
async def login(request: Request): async def login(request: Request):
"""Show login page with appropriate authentication options""" """Show login page with appropriate authentication options."""
return templates.TemplateResponse( return templates.TemplateResponse(
"login.html", "login.html",
{ {
@@ -81,8 +81,9 @@ async def login(request: Request):
"message": request.query_params.get("message"), "message": request.query_params.get("message"),
"show_oauth": OAUTH_CONFIGURED, "show_oauth": OAUTH_CONFIGURED,
"oauth_provider_name": OAUTH_PROVIDER_NAME, "oauth_provider_name": OAUTH_PROVIDER_NAME,
"app_version": settings.version, # Changed from app_version to version "app_version": settings.version,
"csrf_token": getattr(request.state, "csrf_token", ""), "csrf_token": getattr(request.state, "csrf_token", ""),
"allow_signup": settings.allow_local_signup,
}, },
) )
@@ -188,14 +189,45 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
return RedirectResponse(url=f"/login?error=Authentication+failed:+{str(e)}", status_code=status.HTTP_302_FOUND) return RedirectResponse(url=f"/login?error=Authentication+failed:+{str(e)}", status_code=status.HTTP_302_FOUND)
async def auth(request: Request): async def auth(request: Request, db: Session = Depends(get_db)):
"""Handle local username/password authentication""" """Handle local username/password authentication.
Checks LocalUser accounts first, then falls back to admin credentials.
"""
form_data = await request.form() form_data = await request.form()
username = form_data.get("username") username = form_data.get("username")
password = form_data.get("password") password = form_data.get("password")
# --- LocalUser check ---
from app.models import LocalUser as _LocalUser
from app.models import UserProfile as _UserProfile
from app.utils.local_auth import build_session_user as _build_session_user
from app.utils.local_auth import verify_password as _verify_password
local_user = db.query(_LocalUser).filter((_LocalUser.username == username) | (_LocalUser.email == username)).first()
if local_user is not None:
if not _verify_password(password or "", local_user.hashed_password):
logger.warning("[SECURITY] LOCAL_LOGIN_FAILURE user=%s", username)
return RedirectResponse(url="/login?error=Invalid+username+or+password", status_code=302)
if not local_user.is_active:
logger.warning("[SECURITY] LOCAL_LOGIN_UNVERIFIED user=%s", username)
return RedirectResponse(
url="/login?error=Please+verify+your+email+address+before+logging+in",
status_code=302,
)
user_data = _build_session_user(local_user)
request.session["user"] = user_data
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", local_user.email)
profile = db.query(_UserProfile).filter(_UserProfile.user_id == local_user.email).first()
if profile and not profile.onboarding_completed:
post_onboarding = request.session.pop("redirect_after_login", "/upload")
request.session["post_onboarding_redirect"] = post_onboarding
return RedirectResponse(url="/onboarding", status_code=302)
redirect_url = request.session.pop("redirect_after_login", "/upload")
return RedirectResponse(url=redirect_url, status_code=302)
# --- Admin credentials fallback ---
if username == settings.admin_username and password == settings.admin_password: if username == settings.admin_username and password == settings.admin_password:
# Create user session
request.session["user"] = { request.session["user"] = {
"id": "admin", "id": "admin",
"name": "Administrator", "name": "Administrator",
@@ -204,12 +236,11 @@ async def auth(request: Request):
"picture": "/static/images/default-avatar.svg", "picture": "/static/images/default-avatar.svg",
"is_admin": True, "is_admin": True,
} }
logger.info(f"[SECURITY] LOCAL_LOGIN_SUCCESS user={username}") logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", username)
# Redirect to original destination or default
redirect_url = request.session.pop("redirect_after_login", "/upload") redirect_url = request.session.pop("redirect_after_login", "/upload")
return RedirectResponse(url=redirect_url, status_code=302) return RedirectResponse(url=redirect_url, status_code=302)
else: else:
logger.warning(f"[SECURITY] LOCAL_LOGIN_FAILURE user={username}") logger.warning("[SECURITY] LOCAL_LOGIN_FAILURE user=%s", username)
return RedirectResponse(url="/login?error=Invalid+username+or+password", status_code=302) return RedirectResponse(url="/login?error=Invalid+username+or+password", status_code=302)
+17
View File
@@ -172,6 +172,23 @@ class Settings(BaseSettings):
authentik_config_url: Optional[str] = None authentik_config_url: Optional[str] = None
oauth_provider_name: Optional[str] = None # Name to display for the OAuth provider oauth_provider_name: Optional[str] = None # Name to display for the OAuth provider
# Local user signup
allow_local_signup: bool = Field(
default=False,
description=(
"Allow users to self-register with email and password. "
"Requires email (SMTP) to be configured for verification emails. "
"Default: False (registration disabled, admin creates users)."
),
)
# Stripe billing
stripe_secret_key: Optional[str] = None
stripe_publishable_key: Optional[str] = None
stripe_webhook_secret: Optional[str] = None
stripe_success_url: Optional[str] = None # e.g. https://app.example.com/billing/success
stripe_cancel_url: Optional[str] = None # e.g. https://app.example.com/pricing
# IMAP 1 # IMAP 1
imap1_host: Optional[str] = None imap1_host: Optional[str] = None
imap1_port: Optional[int] = 993 imap1_port: Optional[int] = 993
+2
View File
@@ -16,6 +16,7 @@ from starlette.middleware.trustedhost import TrustedHostMiddleware
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from app.api import router as api_router from app.api import router as api_router
from app.api.local_auth import router as local_auth_router
from app.auth import router as auth_router from app.auth import router as auth_router
from app.config import settings from app.config import settings
from app.database import init_db from app.database import init_db
@@ -246,4 +247,5 @@ def test_500():
app.include_router(frontend_router) app.include_router(frontend_router)
app.include_router(files_router) # Explicitly include the files router app.include_router(files_router) # Explicitly include the files router
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(local_auth_router)
app.include_router(api_router, prefix="/api") app.include_router(api_router, prefix="/api")
+28
View File
@@ -172,6 +172,31 @@ class WebhookConfig(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class LocalUser(Base):
"""A locally-registered user authenticated by email and bcrypt password.
Created during the self-registration flow when ``allow_local_signup`` is
enabled. The account is inactive (``is_active=False``) until the user
clicks the verification link sent to their email address.
"""
__tablename__ = "local_users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, nullable=False, index=True)
username = Column(String(64), unique=True, nullable=False, index=True)
display_name = Column(String(255), nullable=True)
hashed_password = Column(String(255), nullable=False)
is_active = Column(Boolean, nullable=False, default=False, server_default="0")
is_admin = Column(Boolean, nullable=False, default=False, server_default="0")
email_verification_token = Column(String(128), nullable=True)
email_verification_sent_at = Column(DateTime(timezone=True), nullable=True)
password_reset_token = Column(String(128), nullable=True)
password_reset_sent_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class UserProfile(Base): class UserProfile(Base):
"""Per-user profile for admin-managed settings in multi-user mode. """Per-user profile for admin-managed settings in multi-user mode.
@@ -214,6 +239,7 @@ class UserProfile(Base):
onboarding_completed_at = Column(DateTime(timezone=True), nullable=True) onboarding_completed_at = Column(DateTime(timezone=True), nullable=True)
contact_email = Column(String(255), nullable=True) contact_email = Column(String(255), nullable=True)
preferred_destination = Column(String(50), nullable=True) preferred_destination = Column(String(50), nullable=True)
stripe_customer_id = Column(String(64), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -262,6 +288,8 @@ class SubscriptionPlan(Base):
sort_order = Column(Integer, nullable=False, default=0) sort_order = Column(Integer, nullable=False, default=0)
features = Column(Text, nullable=True) # JSON-encoded list[str] features = Column(Text, nullable=True) # JSON-encoded list[str]
api_access = Column(Boolean, nullable=False, default=False) api_access = Column(Boolean, nullable=False, default=False)
stripe_price_id_monthly = Column(String(64), nullable=True)
stripe_price_id_yearly = Column(String(64), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+187
View File
@@ -0,0 +1,187 @@
"""Utilities for local (email/password) user authentication.
Provides password hashing (bcrypt), secure token generation, and
synchronous SMTP email helpers for account verification and password
reset flows. No external dependencies beyond bcrypt (already in
requirements.txt) and Python stdlib.
"""
import logging
import secrets
import smtplib
import socket
from datetime import datetime, timedelta, timezone
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import bcrypt
from app.config import settings
logger = logging.getLogger(__name__)
TOKEN_BYTES = 32 # 256 bits of entropy
TOKEN_EXPIRY_HOURS = 24 # verification + reset tokens expire after 24 h
def hash_password(plain: str) -> str:
"""Return a bcrypt hash of *plain*. Stores result as a UTF-8 string."""
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
"""Return True when *plain* matches the stored bcrypt *hashed* string."""
try:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
except Exception:
return False
def generate_token() -> str:
"""Return a 256-bit URL-safe random token string."""
return secrets.token_urlsafe(TOKEN_BYTES)
def is_token_expired(sent_at: datetime | None) -> bool:
"""Return True when *sent_at* is None or older than TOKEN_EXPIRY_HOURS."""
if sent_at is None:
return True
return datetime.now(tz=timezone.utc) > sent_at.replace(tzinfo=timezone.utc) + timedelta(hours=TOKEN_EXPIRY_HOURS)
def _smtp_send(subject: str, html_body: str, plain_body: str, recipient: str) -> None:
"""Send an HTML email via the configured SMTP server.
Args:
subject: Email subject line.
html_body: HTML version of the email body.
plain_body: Plain-text version of the email body.
recipient: Recipient email address.
Raises:
RuntimeError: When SMTP is not configured or sending fails.
"""
if not settings.email_host:
raise RuntimeError("SMTP is not configured (EMAIL_HOST missing). Cannot send email.")
sender = settings.email_sender or settings.email_username or "noreply@docuelevate.local"
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = sender
msg["To"] = recipient
msg.attach(MIMEText(plain_body, "plain", "utf-8"))
msg.attach(MIMEText(html_body, "html", "utf-8"))
try:
socket.gethostbyname(settings.email_host)
except socket.gaierror as exc:
raise RuntimeError(f"Cannot resolve SMTP host {settings.email_host!r}: {exc}") from exc
with smtplib.SMTP(settings.email_host, settings.email_port or 587, timeout=30) as server:
if settings.email_use_tls:
server.starttls()
if settings.email_username and settings.email_password:
server.login(settings.email_username, settings.email_password)
server.send_message(msg)
logger.info("Sent %r to %s", subject, recipient)
def send_verification_email(email: str, username: str, token: str, base_url: str) -> None:
"""Send a double opt-in verification email to *email*.
Args:
email: Recipient email address.
username: The user's chosen username (used in greeting).
token: The verification token to embed in the link.
base_url: The base URL of the application (e.g. https://app.example.com).
"""
verify_url = f"{base_url}/verify-email?token={token}"
subject = "Verify your DocuElevate account"
html_body = f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body style="font-family:Arial,sans-serif;background:#f4f4f5;margin:0;padding:32px;">
<div style="max-width:480px;margin:0 auto;background:#fff;border-radius:12px;padding:32px;box-shadow:0 2px 8px rgba(0,0,0,.08);">
<h1 style="color:#4f46e5;font-size:24px;margin-bottom:8px;">Welcome to DocuElevate, {username}!</h1>
<p style="color:#374151;">Thanks for signing up. Please confirm your email address to activate your account.</p>
<div style="text-align:center;margin:32px 0;">
<a href="{verify_url}"
style="display:inline-block;background:#4f46e5;color:#fff;text-decoration:none;padding:14px 32px;border-radius:8px;font-weight:600;font-size:16px;">
Confirm my email address
</a>
</div>
<p style="color:#6b7280;font-size:13px;">This link expires in 24 hours. If you did not create an account, you can safely ignore this email.</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
<p style="color:#9ca3af;font-size:12px;text-align:center;">DocuElevate &middot; Intelligent Document Processing</p>
</div>
</body>
</html>"""
plain_body = (
f"Welcome to DocuElevate, {username}!\n\n"
f"Please verify your email address by visiting:\n{verify_url}\n\n"
"This link expires in 24 hours."
)
_smtp_send(subject, html_body, plain_body, email)
def send_password_reset_email(email: str, username: str, token: str, base_url: str) -> None:
"""Send a password reset email to *email*.
Args:
email: Recipient email address.
username: The user's username (used in greeting).
token: The password reset token to embed in the link.
base_url: The base URL of the application.
"""
reset_url = f"{base_url}/reset-password?token={token}"
subject = "Reset your DocuElevate password"
html_body = f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body style="font-family:Arial,sans-serif;background:#f4f4f5;margin:0;padding:32px;">
<div style="max-width:480px;margin:0 auto;background:#fff;border-radius:12px;padding:32px;box-shadow:0 2px 8px rgba(0,0,0,.08);">
<h1 style="color:#4f46e5;font-size:24px;margin-bottom:8px;">Password Reset</h1>
<p style="color:#374151;">Hi {username}, you requested a password reset for your DocuElevate account.</p>
<div style="text-align:center;margin:32px 0;">
<a href="{reset_url}"
style="display:inline-block;background:#4f46e5;color:#fff;text-decoration:none;padding:14px 32px;border-radius:8px;font-weight:600;font-size:16px;">
Reset my password
</a>
</div>
<p style="color:#6b7280;font-size:13px;">This link expires in 24 hours. If you did not request a password reset, you can safely ignore this email.</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
<p style="color:#9ca3af;font-size:12px;text-align:center;">DocuElevate &middot; Intelligent Document Processing</p>
</div>
</body>
</html>"""
plain_body = (
f"Hi {username},\n\n"
f"You requested a password reset. Visit the link below:\n{reset_url}\n\n"
"This link expires in 24 hours. If you did not request this, ignore this email."
)
_smtp_send(subject, html_body, plain_body, email)
def build_session_user(user: object) -> dict:
"""Build the session user dict for a LocalUser, matching the OAuth session format.
Args:
user: A ``LocalUser`` ORM instance.
Returns:
Dict suitable for storing in ``request.session["user"]``.
"""
from app.auth import get_gravatar_url
return {
"sub": user.email, # type: ignore[attr-defined]
"id": user.email, # type: ignore[attr-defined]
"email": user.email, # type: ignore[attr-defined]
"preferred_username": user.username, # type: ignore[attr-defined]
"name": user.display_name or user.username, # type: ignore[attr-defined]
"picture": get_gravatar_url(user.email), # type: ignore[attr-defined]
"is_admin": bool(user.is_admin), # type: ignore[attr-defined]
"auth_method": "local",
}
+172
View File
@@ -0,0 +1,172 @@
# Billing Setup Guide
This guide covers how to configure Stripe billing and local user sign-up in DocuElevate.
## Table of Contents
- [Local User Sign-up](#local-user-sign-up)
- [Stripe Billing Integration](#stripe-billing-integration)
- [Prerequisites](#prerequisites)
- [Configuration](#configuration)
- [Setting Up Plans](#setting-up-plans)
- [Webhook Configuration](#webhook-configuration)
- [Billing Flows](#billing-flows)
- [Compliance Notes](#compliance-notes)
---
## Local User Sign-up
By default, user accounts are created by an administrator. To allow users to self-register with an email address and password, set `ALLOW_LOCAL_SIGNUP=true`.
> **Note:** SMTP must be configured before enabling local sign-up. New accounts require email verification before they can log in.
### Configuration
```bash
ALLOW_LOCAL_SIGNUP=true
# SMTP (required for verification emails)
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_USERNAME=noreply@example.com
EMAIL_PASSWORD=yourpassword
EMAIL_USE_TLS=true
EMAIL_SENDER=DocuElevate <noreply@example.com>
```
### Sign-up Flow
1. User visits `/signup` and fills out the registration form.
2. DocuElevate sends a verification email with a 24-hour token link.
3. User clicks the link — their account is activated and they are signed in.
4. First-time users are redirected to the onboarding wizard.
### Password Reset Flow
1. User clicks "Forgot password?" on the login page.
2. User enters their email address.
3. DocuElevate sends a password reset email with a 24-hour token link.
4. User clicks the link, enters a new password, and is redirected to sign in.
### Security
- Passwords are hashed with bcrypt (12 rounds).
- Verification and reset tokens are 256-bit URL-safe random strings.
- All tokens expire after 24 hours.
- Sign-up and login endpoints return generic error messages to prevent user enumeration.
---
## Stripe Billing Integration
DocuElevate integrates with [Stripe](https://stripe.com) to handle subscription payments. Stripe acts as a data processor under a Data Processing Agreement (DPA) and is SOC 2 Type II certified.
### Prerequisites
- A Stripe account (sign up at [stripe.com](https://stripe.com))
- Products and prices created in the Stripe Dashboard for each paid plan
- A publicly reachable webhook endpoint (or use [Stripe CLI](https://stripe.com/docs/stripe-cli) for local testing)
### Configuration
```bash
STRIPE_SECRET_KEY=sk_live_... # Your Stripe secret key
STRIPE_PUBLISHABLE_KEY=pk_live_... # Your Stripe publishable key (for frontend)
STRIPE_WEBHOOK_SECRET=whsec_... # Webhook signing secret
STRIPE_SUCCESS_URL=https://app.example.com/api/billing/success # Optional override
STRIPE_CANCEL_URL=https://app.example.com/pricing # Optional override
```
> **Security:** Never commit your Stripe secret key. Store it in your environment or secrets manager.
### Setting Up Plans
After starting DocuElevate, go to **Admin → Plans** to configure each plan:
1. Open the **Plan Designer** for a paid tier (e.g. Starter, Professional).
2. Enter the **Stripe Price ID (monthly)** from your Stripe Dashboard (e.g. `price_1OtAbc...`).
3. Optionally enter the **Stripe Price ID (yearly)** for annual billing.
4. Save the plan.
Stripe Price IDs look like `price_1OtAbcDefGhIjKlMnOpQrSt`. Find them in **Products** in your Stripe Dashboard.
### Webhook Configuration
Stripe webhooks allow DocuElevate to sync subscription status in real time.
#### Stripe Dashboard setup
1. Go to **Developers → Webhooks** in the Stripe Dashboard.
2. Click **Add endpoint**.
3. Set the endpoint URL to: `https://your-app-domain.com/api/billing/webhook`
4. Select the following events:
- `checkout.session.completed`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_failed`
5. Copy the **Signing secret** and set `STRIPE_WEBHOOK_SECRET` in your environment.
#### Local testing with Stripe CLI
```bash
# Install Stripe CLI and log in
stripe login
# Forward webhooks to your local server
stripe listen --forward-to http://localhost:8000/api/billing/webhook
# Trigger a test event
stripe trigger checkout.session.completed
```
### Billing Flows
#### Subscribe to a plan
1. User visits `/pricing`.
2. User clicks the **CTA button** on a paid plan.
3. DocuElevate calls `POST /api/billing/create-checkout-session`.
4. User is redirected to Stripe Checkout.
5. After payment, Stripe fires `checkout.session.completed`.
6. DocuElevate webhook handler activates the subscription tier.
7. User is redirected to `/api/billing/success`.
#### Manage or cancel subscription
1. User visits their account settings.
2. DocuElevate calls `POST /api/billing/create-portal-session`.
3. User is redirected to the Stripe Customer Portal.
4. User can update payment method, upgrade, downgrade, or cancel.
5. Stripe fires `customer.subscription.updated` or `customer.subscription.deleted`.
6. DocuElevate webhook handler syncs the change.
#### Cancellation
When a subscription is cancelled, Stripe fires `customer.subscription.deleted` and DocuElevate automatically downgrades the user to the free tier.
---
## Compliance Notes
| Topic | Details |
|-------|---------|
| **GDPR** | Stripe acts as a data processor. A Data Processing Agreement (DPA) is available in the Stripe Dashboard. Stripe supports EU data residency. |
| **SOC 2** | Stripe is SOC 2 Type II certified. |
| **EU VAT** | Configure [Stripe Tax](https://stripe.com/tax) in the Stripe Dashboard for automatic VAT collection. |
| **PCI DSS** | Card data is handled entirely by Stripe. DocuElevate never sees or stores card details. |
---
## Environment Variable Reference
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `ALLOW_LOCAL_SIGNUP` | bool | `false` | Allow users to self-register with email/password |
| `STRIPE_SECRET_KEY` | string | — | Stripe API secret key |
| `STRIPE_PUBLISHABLE_KEY` | string | — | Stripe API publishable key |
| `STRIPE_WEBHOOK_SECRET` | string | — | Webhook signing secret from Stripe Dashboard |
| `STRIPE_SUCCESS_URL` | string | — | Override redirect URL after successful checkout |
| `STRIPE_CANCEL_URL` | string | — | Override redirect URL when checkout is cancelled |
See [ConfigurationGuide.md](./ConfigurationGuide.md) for the full environment variable reference.
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Subscription Activated</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center" role="main">
<div class="flex justify-center mb-6">
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
</div>
<div class="flex justify-center mb-4">
<div class="bg-green-100 rounded-full p-4">
<i class="fas fa-circle-check text-green-600 text-5xl" aria-hidden="true"></i>
</div>
</div>
<h1 class="text-2xl font-bold text-gray-800 mb-2">You're all set!</h1>
<p class="text-gray-600 mb-8">
Your subscription has been activated. Thank you for choosing DocuElevate!
</p>
<div class="space-y-3">
<a href="/subscription"
class="block w-full py-3 px-4 rounded-lg border-2 border-indigo-600 text-indigo-600 font-semibold hover:bg-indigo-50 transition focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
style="min-height:44px;display:flex;align-items:center;justify-content:center;"
>
<i class="fas fa-credit-card mr-2" aria-hidden="true"></i>Manage subscription
</a>
<a href="/upload"
class="block w-full py-3 px-4 rounded-lg bg-indigo-600 text-white font-semibold hover:bg-indigo-700 transition focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
style="min-height:44px;display:flex;align-items:center;justify-content:center;"
>
<i class="fas fa-arrow-right mr-2" aria-hidden="true"></i>Go to dashboard
</a>
</div>
</main>
</body>
</html>
+9
View File
@@ -78,6 +78,15 @@
Return to Home Return to Home
</a> </a>
</div> </div>
{% if allow_signup %}
<div class="mt-4 text-center">
<span class="text-sm text-gray-600">Don't have an account?</span>
<a href="/signup" class="ml-1 text-sm font-medium text-indigo-600 hover:text-indigo-500">
Create account
</a>
</div>
{% endif %}
</main> </main>
<div class="fixed bottom-4 text-center w-full text-xs text-gray-500"> <div class="fixed bottom-4 text-center w-full text-xs text-gray-500">
DocuElevate {{ app_version|default('', true) }} DocuElevate {{ app_version|default('', true) }}
+131
View File
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Reset Password</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full" role="main">
<div class="flex justify-center mb-6">
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
</div>
<h1 class="text-2xl font-bold text-center text-gray-800 mb-2">Set a new password</h1>
<p class="text-center text-gray-500 text-sm mb-6">Enter your new password below.</p>
<div
x-data="{
token: '{{ token | default('', true) }}',
new_password: '',
new_password_confirm: '',
loading: false,
error: '',
success: false,
async submit() {
this.error = '';
if (this.new_password !== this.new_password_confirm) {
this.error = 'Passwords do not match.';
return;
}
this.loading = true;
try {
const resp = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrf_token }}' },
body: JSON.stringify({
token: this.token,
new_password: this.new_password,
new_password_confirm: this.new_password_confirm
})
});
if (resp.ok) {
this.success = true;
} else {
const data = await resp.json();
this.error = data.detail || 'Password reset failed. Please try again.';
}
} catch(e) {
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
}
}"
>
<div x-show="success" x-cloak class="text-center py-4">
<div class="flex justify-center mb-4">
<div class="bg-green-100 rounded-full p-4">
<i class="fas fa-check-circle text-green-600 text-4xl" aria-hidden="true"></i>
</div>
</div>
<p class="text-green-700 font-semibold mb-4">Password updated successfully!</p>
<a href="/login"
class="inline-block py-2 px-6 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
style="min-height:44px;display:flex;align-items:center;justify-content:center;"
>Sign in</a>
</div>
<form x-show="!success" @submit.prevent="submit" class="space-y-4" novalidate>
<div x-show="error" x-cloak
class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded"
role="alert" aria-live="polite">
<p x-text="error"></p>
</div>
<div>
<label for="new_password" class="block text-sm font-medium text-gray-700">New password <span aria-hidden="true" class="text-red-500">*</span></label>
<input
type="password" id="new_password" name="new_password" required
x-model="new_password"
autocomplete="new-password"
minlength="8" maxlength="128"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-500 focus:ring-opacity-50"
aria-required="true"
aria-describedby="pw-hint"
>
<p id="pw-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
</div>
<div>
<label for="new_password_confirm" class="block text-sm font-medium text-gray-700">Confirm new password <span aria-hidden="true" class="text-red-500">*</span></label>
<input
type="password" id="new_password_confirm" name="new_password_confirm" required
x-model="new_password_confirm"
autocomplete="new-password"
minlength="8" maxlength="128"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-500 focus:ring-opacity-50"
aria-required="true"
>
</div>
<button
type="submit"
:disabled="loading"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
style="min-height:44px;"
>
<span x-show="!loading">Update password</span>
<span x-show="loading" x-cloak>
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Updating&hellip;
</span>
</button>
</form>
</div>
<div class="mt-6 text-center">
<a href="/login" class="text-sm font-medium text-blue-600 hover:text-blue-500">
Back to sign in
</a>
</div>
</main>
<div class="fixed bottom-4 text-center w-full text-xs text-gray-500">
DocuElevate {{ app_version|default('', true) }}
</div>
</body>
</html>
+45
View File
@@ -117,6 +117,13 @@
<a href="/login" <a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg border-2 border-blue-600 text-blue-600 font-semibold hover:bg-blue-50 transition" class="block w-full text-center py-3 px-4 rounded-lg border-2 border-blue-600 text-blue-600 font-semibold hover:bg-blue-50 transition"
>{{ tier.cta }}</a> >{{ tier.cta }}</a>
{% elif tier.stripe_price_id_monthly or tier.stripe_price_id_yearly %}
<button
type="button"
onclick="startCheckout('{{ tier.id }}')"
class="block w-full text-center py-3 px-4 rounded-lg {% if tier.highlight %}bg-indigo-600 text-white font-semibold hover:bg-indigo-700 transition shadow-md{% else %}bg-blue-600 text-white font-semibold hover:bg-blue-700 transition{% endif %}"
style="min-height:44px;"
>{{ tier.cta }}</button>
{% elif tier.highlight %} {% elif tier.highlight %}
<a href="/login" <a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg bg-indigo-600 text-white font-semibold hover:bg-indigo-700 transition shadow-md" class="block w-full text-center py-3 px-4 rounded-lg bg-indigo-600 text-white font-semibold hover:bg-indigo-700 transition shadow-md"
@@ -377,3 +384,41 @@
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_scripts %}
<script>
/**
* Initiate a Stripe Checkout session for the given plan.
* Uses the currently-selected billing cycle toggle (monthly/yearly).
*
* @param {string} planId - The plan identifier (e.g. 'starter', 'professional').
*/
async function startCheckout(planId) {
const cycleEl = document.querySelector('[data-billing-cycle]');
const billingCycle = (cycleEl && cycleEl.dataset.billingCycle) ? cycleEl.dataset.billingCycle : 'monthly';
try {
const resp = await fetch('/api/billing/create-checkout-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan_id: planId, billing_cycle: billingCycle })
});
if (resp.status === 401) {
window.location.href = '/login?message=Please+sign+in+to+subscribe';
return;
}
if (resp.ok) {
const data = await resp.json();
if (data.checkout_url) {
window.location.href = data.checkout_url;
return;
}
}
const data = await resp.json().catch(() => ({}));
alert(data.detail || 'Unable to start checkout. Please try again.');
} catch (e) {
alert('Network error. Please try again.');
}
}
</script>
{% endblock %}
+156
View File
@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Create Account</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full" role="main">
<div class="flex justify-center mb-6">
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
</div>
<h1 class="text-2xl font-bold text-center text-gray-800 mb-2">Create your account</h1>
<p class="text-center text-gray-500 text-sm mb-6">Already have an account?
<a href="/login" class="text-blue-600 hover:text-blue-500 font-medium">Sign in</a>
</p>
<div
x-data="{
email: '',
username: '',
display_name: '',
password: '',
password_confirm: '',
loading: false,
error: '',
async submit() {
this.error = '';
if (this.password !== this.password_confirm) {
this.error = 'Passwords do not match.';
return;
}
this.loading = true;
try {
const resp = await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrf_token }}' },
body: JSON.stringify({
email: this.email,
username: this.username,
display_name: this.display_name || null,
password: this.password,
password_confirm: this.password_confirm
})
});
if (resp.ok) {
window.location.href = '/verify-email-sent';
} else {
const data = await resp.json();
this.error = data.detail || 'Registration failed. Please try again.';
}
} catch(e) {
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
}
}"
>
<div x-show="error" x-cloak
class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 rounded"
role="alert"
aria-live="polite">
<p x-text="error"></p>
</div>
<form @submit.prevent="submit" class="space-y-4" novalidate>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email address <span aria-hidden="true" class="text-red-500">*</span></label>
<input
type="email" id="email" name="email" required autocomplete="email"
x-model="email"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
aria-required="true"
>
</div>
<div>
<label for="username" class="block text-sm font-medium text-gray-700">Username <span aria-hidden="true" class="text-red-500">*</span></label>
<input
type="text" id="username" name="username" required autocomplete="username"
x-model="username"
pattern="^[a-zA-Z0-9_-]+$"
minlength="3" maxlength="64"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
aria-required="true"
aria-describedby="username-hint"
>
<p id="username-hint" class="mt-1 text-xs text-gray-500">364 characters. Letters, numbers, hyphens and underscores only.</p>
</div>
<div>
<label for="display_name" class="block text-sm font-medium text-gray-700">Display name <span class="text-gray-400">(optional)</span></label>
<input
type="text" id="display_name" name="display_name" autocomplete="name"
x-model="display_name"
maxlength="255"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password <span aria-hidden="true" class="text-red-500">*</span></label>
<input
type="password" id="password" name="password" required autocomplete="new-password"
x-model="password"
minlength="8" maxlength="128"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
aria-required="true"
aria-describedby="password-hint"
>
<p id="password-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
</div>
<div>
<label for="password_confirm" class="block text-sm font-medium text-gray-700">Confirm password <span aria-hidden="true" class="text-red-500">*</span></label>
<input
type="password" id="password_confirm" name="password_confirm" required autocomplete="new-password"
x-model="password_confirm"
minlength="8" maxlength="128"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
aria-required="true"
>
</div>
<button
type="submit"
:disabled="loading"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
style="min-height:44px;"
>
<span x-show="!loading">Create account</span>
<span x-show="loading" x-cloak>
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Creating account&hellip;
</span>
</button>
</form>
</div>
<div class="mt-6 text-center">
<a href="/" class="text-sm font-medium text-blue-600 hover:text-blue-500">
Return to Home
</a>
</div>
</main>
<div class="fixed bottom-4 text-center w-full text-xs text-gray-500">
DocuElevate {{ app_version|default('', true) }}
</div>
</body>
</html>
+108
View File
@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Verify Your Email</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center" role="main">
<div class="flex justify-center mb-6">
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
</div>
<div class="flex justify-center mb-4">
<div class="bg-indigo-100 rounded-full p-4">
<i class="fas fa-envelope-circle-check text-indigo-600 text-4xl" aria-hidden="true"></i>
</div>
</div>
<h1 class="text-2xl font-bold text-gray-800 mb-2">Check your inbox</h1>
<p class="text-gray-600 mb-6">
We've sent you a verification email. Please click the link in the email to activate your account.
</p>
<p class="text-sm text-gray-500 mb-8">
The link expires in 24 hours. If you don't see the email, check your spam folder.
</p>
<div
x-data="{ email: '', loading: false, message: '', error: '' }"
class="border-t border-gray-100 pt-6"
>
<p class="text-sm text-gray-600 mb-3">Didn't receive it?</p>
<div x-show="message" x-cloak
class="bg-green-100 border-l-4 border-green-500 text-green-700 p-3 mb-4 rounded text-sm"
role="status" aria-live="polite">
<p x-text="message"></p>
</div>
<div x-show="error" x-cloak
class="bg-red-100 border-l-4 border-red-500 text-red-700 p-3 mb-4 rounded text-sm"
role="alert" aria-live="polite">
<p x-text="error"></p>
</div>
<form
@submit.prevent="async () => {
error = ''; message = '';
if (!email) { error = 'Please enter your email address.'; return; }
loading = true;
try {
const resp = await fetch('/api/auth/resend-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
if (resp.ok) {
message = 'Verification email resent. Please check your inbox.';
} else {
error = 'Something went wrong. Please try again.';
}
} catch(e) {
error = 'Network error. Please try again.';
} finally {
loading = false;
}
}"
class="space-y-3"
novalidate
>
<div>
<label for="resend-email" class="sr-only">Email address</label>
<input
type="email" id="resend-email" name="email"
x-model="email"
placeholder="Enter your email address"
autocomplete="email"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-500 focus:ring-opacity-50 text-sm"
aria-label="Email address for resend"
style="min-height:44px;"
>
</div>
<button
type="submit"
:disabled="loading"
class="w-full flex justify-center py-2 px-4 border border-indigo-600 rounded-md text-sm font-medium text-indigo-600 hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
style="min-height:44px;"
>
<span x-show="!loading">Resend verification email</span>
<span x-show="loading" x-cloak>
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Sending&hellip;
</span>
</button>
</form>
</div>
<div class="mt-6 text-center">
<a href="/login" class="text-sm font-medium text-blue-600 hover:text-blue-500">
Back to sign in
</a>
</div>
</main>
</body>
</html>
@@ -0,0 +1,49 @@
"""Add local_users table and billing columns
Revision ID: 018_add_local_users_and_billing
Revises: 017_add_onboarding_fields
Create Date: 2026-03-09
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "018_add_local_users_and_billing"
down_revision: Union[str, None] = "017_add_onboarding_fields"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Create local_users table and add billing columns."""
op.create_table(
"local_users",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("username", sa.String(64), nullable=False),
sa.Column("display_name", sa.String(255), nullable=True),
sa.Column("hashed_password", sa.String(255), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("email_verification_token", sa.String(128), nullable=True),
sa.Column("email_verification_sent_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("password_reset_token", sa.String(128), nullable=True),
sa.Column("password_reset_sent_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
sa.UniqueConstraint("username"),
)
op.add_column("user_profiles", sa.Column("stripe_customer_id", sa.String(64), nullable=True))
op.add_column("subscription_plans", sa.Column("stripe_price_id_monthly", sa.String(64), nullable=True))
op.add_column("subscription_plans", sa.Column("stripe_price_id_yearly", sa.String(64), nullable=True))
def downgrade() -> None:
"""Reverse the migration."""
op.drop_column("subscription_plans", "stripe_price_id_yearly")
op.drop_column("subscription_plans", "stripe_price_id_monthly")
op.drop_column("user_profiles", "stripe_customer_id")
op.drop_table("local_users")
+1
View File
@@ -44,3 +44,4 @@ pytesseract>=0.3.10 # Python wrapper for Tesseract OCR
pdf2image>=1.17.0 # Convert PDF pages to images (used by Tesseract and EasyOCR providers) pdf2image>=1.17.0 # Convert PDF pages to images (used by Tesseract and EasyOCR providers)
ocrmypdf>=16.0.0,<18.0.0 # Post-processing: embeds searchable text layers into PDFs via Tesseract ocrmypdf>=16.0.0,<18.0.0 # Post-processing: embeds searchable text layers into PDFs via Tesseract
meilisearch>=0.31.0 # Full-text search engine client meilisearch>=0.31.0 # Full-text search engine client
stripe>=7.0.0,<15.0.0 # Stripe billing SDK (MIT license)
+12 -5
View File
@@ -729,9 +729,16 @@ class TestEnsureUserProfile:
class TestAuthFunction: class TestAuthFunction:
"""Tests for auth() function (local authentication).""" """Tests for auth() function (local authentication)."""
def _make_mock_db(self):
"""Create a mock DB that returns None for LocalUser queries (no local users)."""
mock_db = MagicMock()
# query().filter().first() returns None → no LocalUser found
mock_db.query.return_value.filter.return_value.first.return_value = None
return mock_db
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auth_success(self): async def test_auth_success(self):
"""Test successful local authentication.""" """Test successful local authentication (admin fallback)."""
from app.auth import auth from app.auth import auth
mock_request = MagicMock(spec=Request) mock_request = MagicMock(spec=Request)
@@ -743,7 +750,7 @@ class TestAuthFunction:
mock_settings.admin_username = "testadmin" mock_settings.admin_username = "testadmin"
mock_settings.admin_password = "testpass" mock_settings.admin_password = "testpass"
result = await auth(mock_request) result = await auth(mock_request, db=self._make_mock_db())
assert isinstance(result, RedirectResponse) assert isinstance(result, RedirectResponse)
assert result.status_code == 302 assert result.status_code == 302
@@ -766,7 +773,7 @@ class TestAuthFunction:
mock_settings.admin_username = "testadmin" mock_settings.admin_username = "testadmin"
mock_settings.admin_password = "testpass" mock_settings.admin_password = "testpass"
result = await auth(mock_request) result = await auth(mock_request, db=self._make_mock_db())
assert isinstance(result, RedirectResponse) assert isinstance(result, RedirectResponse)
assert "/login?error=Invalid+username+or+password" in result.headers["location"] assert "/login?error=Invalid+username+or+password" in result.headers["location"]
@@ -786,7 +793,7 @@ class TestAuthFunction:
mock_settings.admin_username = "testadmin" mock_settings.admin_username = "testadmin"
mock_settings.admin_password = "testpass" mock_settings.admin_password = "testpass"
result = await auth(mock_request) result = await auth(mock_request, db=self._make_mock_db())
assert isinstance(result, RedirectResponse) assert isinstance(result, RedirectResponse)
assert "/login?error=Invalid+username+or+password" in result.headers["location"] assert "/login?error=Invalid+username+or+password" in result.headers["location"]
@@ -805,7 +812,7 @@ class TestAuthFunction:
mock_settings.admin_username = "testadmin" mock_settings.admin_username = "testadmin"
mock_settings.admin_password = "testpass" mock_settings.admin_password = "testpass"
result = await auth(mock_request) result = await auth(mock_request, db=self._make_mock_db())
assert isinstance(result, RedirectResponse) assert isinstance(result, RedirectResponse)
assert result.headers["location"] == "/settings" assert result.headers["location"] == "/settings"
+486
View File
@@ -0,0 +1,486 @@
"""Tests for the Stripe billing API endpoints.
Covers:
- POST /api/billing/create-checkout-session
- POST /api/billing/create-portal-session
- POST /api/billing/webhook (all event types)
- GET /api/billing/success
- Internal helpers: _handle_stripe_event, _on_checkout_completed,
_on_subscription_updated, _on_subscription_deleted
"""
import json
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.billing import (
_handle_stripe_event,
_on_checkout_completed,
_on_subscription_deleted,
_on_subscription_updated,
_resolve_plan_id_from_price,
_resolve_user_id_from_customer,
)
from app.database import Base, get_db
from app.models import SubscriptionPlan, UserProfile
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def bill_engine():
"""In-memory SQLite engine for billing tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def bill_session(bill_engine):
"""DB session for one test."""
Session = sessionmaker(bind=bill_engine)
session = Session()
yield session
session.close()
@pytest.fixture()
def bill_client(bill_engine):
"""TestClient with DB dependency overridden and a logged-in session."""
from app.main import app
Session = sessionmaker(bind=bill_engine)
def override_get_db():
db = Session()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
yield client
app.dependency_overrides.pop(get_db, None)
@pytest.fixture()
def starter_plan(bill_session):
"""A SubscriptionPlan with Stripe price IDs in the DB."""
plan = SubscriptionPlan(
plan_id="starter",
name="Starter",
price_monthly=9.0,
price_yearly=90.0,
trial_days=0,
stripe_price_id_monthly="price_monthly_starter",
stripe_price_id_yearly="price_yearly_starter",
)
bill_session.add(plan)
bill_session.commit()
return plan
@pytest.fixture()
def user_profile(bill_session):
"""A UserProfile for user1@example.com."""
profile = UserProfile(
user_id="user1@example.com",
display_name="Test User",
stripe_customer_id=None,
)
bill_session.add(profile)
bill_session.commit()
return profile
# ---------------------------------------------------------------------------
# Tests: _get_stripe returns None when not configured
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_get_stripe_returns_none_when_not_configured():
"""_get_stripe returns None when stripe_secret_key is not set."""
from app.api.billing import _get_stripe
with patch("app.api.billing.settings") as mock_settings:
mock_settings.stripe_secret_key = None
result = _get_stripe()
assert result is None
@pytest.mark.unit
def test_get_stripe_returns_client_when_configured():
"""_get_stripe returns a StripeClient when key is configured."""
from app.api.billing import _get_stripe
with patch("app.api.billing.settings") as mock_settings:
mock_settings.stripe_secret_key = "sk_test_fake"
result = _get_stripe()
assert result is not None
# ---------------------------------------------------------------------------
# Tests: create-checkout-session
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_create_checkout_session_stripe_not_configured(bill_client):
"""POST /api/billing/create-checkout-session returns 503 when Stripe not set."""
with patch("app.api.billing._get_stripe", return_value=None):
resp = bill_client.post(
"/api/billing/create-checkout-session",
json={"plan_id": "starter", "billing_cycle": "monthly"},
)
assert resp.status_code == 503
@pytest.mark.integration
def test_create_checkout_session_plan_not_found(bill_client):
"""POST /api/billing/create-checkout-session returns 404 for unknown plan."""
mock_client = MagicMock()
with patch("app.api.billing._get_stripe", return_value=mock_client):
resp = bill_client.post(
"/api/billing/create-checkout-session",
json={"plan_id": "nonexistent", "billing_cycle": "monthly"},
)
assert resp.status_code == 404
@pytest.mark.integration
def test_create_checkout_session_no_price_id(bill_client, bill_session):
"""POST /api/billing/create-checkout-session returns 404 when price ID not set."""
plan = SubscriptionPlan(
plan_id="noprice",
name="No Price",
price_monthly=5.0,
price_yearly=50.0,
trial_days=0,
stripe_price_id_monthly=None,
stripe_price_id_yearly=None,
)
bill_session.add(plan)
bill_session.commit()
mock_client = MagicMock()
with patch("app.api.billing._get_stripe", return_value=mock_client):
resp = bill_client.post(
"/api/billing/create-checkout-session",
json={"plan_id": "noprice", "billing_cycle": "monthly"},
)
assert resp.status_code == 404
@pytest.mark.integration
def test_create_checkout_session_success(bill_client, starter_plan, user_profile):
"""POST /api/billing/create-checkout-session returns checkout_url on success."""
mock_client = MagicMock()
mock_customer = MagicMock()
mock_customer.id = "cus_test123"
mock_session = MagicMock()
mock_session.id = "cs_test456"
mock_session.url = "https://checkout.stripe.com/test"
mock_client.customers.create.return_value = mock_customer
mock_client.checkout.sessions.create.return_value = mock_session
with (
patch("app.api.billing._get_stripe", return_value=mock_client),
patch("app.api.billing.get_current_owner_id", return_value="user1@example.com"),
):
resp = bill_client.post(
"/api/billing/create-checkout-session",
json={"plan_id": "starter", "billing_cycle": "monthly"},
)
assert resp.status_code == 200
data = resp.json()
assert "checkout_url" in data
assert data["checkout_url"] == "https://checkout.stripe.com/test"
@pytest.mark.integration
def test_create_checkout_session_yearly(bill_client, starter_plan, user_profile):
"""POST /api/billing/create-checkout-session uses yearly price ID for yearly cycle."""
mock_client = MagicMock()
mock_customer = MagicMock()
mock_customer.id = "cus_test123"
mock_session = MagicMock()
mock_session.id = "cs_test456"
mock_session.url = "https://checkout.stripe.com/yearly"
mock_client.customers.create.return_value = mock_customer
mock_client.checkout.sessions.create.return_value = mock_session
with (
patch("app.api.billing._get_stripe", return_value=mock_client),
patch("app.api.billing.get_current_owner_id", return_value="user1@example.com"),
):
resp = bill_client.post(
"/api/billing/create-checkout-session",
json={"plan_id": "starter", "billing_cycle": "yearly"},
)
assert resp.status_code == 200
# Verify yearly price ID was used
call_params = mock_client.checkout.sessions.create.call_args[1]["params"]
assert call_params["line_items"][0]["price"] == "price_yearly_starter"
# ---------------------------------------------------------------------------
# Tests: create-portal-session
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_create_portal_session_stripe_not_configured(bill_client):
"""POST /api/billing/create-portal-session returns 503 when not configured."""
with patch("app.api.billing._get_stripe", return_value=None):
resp = bill_client.post("/api/billing/create-portal-session", json={})
assert resp.status_code == 503
@pytest.mark.integration
def test_create_portal_session_no_customer(bill_client, user_profile):
"""POST /api/billing/create-portal-session returns 404 when no Stripe customer."""
mock_client = MagicMock()
with (
patch("app.api.billing._get_stripe", return_value=mock_client),
patch("app.api.billing.get_current_owner_id", return_value="user1@example.com"),
):
resp = bill_client.post("/api/billing/create-portal-session", json={})
assert resp.status_code == 404
@pytest.mark.integration
def test_create_portal_session_success(bill_client, bill_session, user_profile):
"""POST /api/billing/create-portal-session returns portal_url on success."""
user_profile.stripe_customer_id = "cus_existing"
bill_session.commit()
mock_client = MagicMock()
mock_portal = MagicMock()
mock_portal.url = "https://billing.stripe.com/portal/test"
mock_client.billing_portal.sessions.create.return_value = mock_portal
with (
patch("app.api.billing._get_stripe", return_value=mock_client),
patch("app.api.billing.get_current_owner_id", return_value="user1@example.com"),
):
resp = bill_client.post("/api/billing/create-portal-session", json={})
assert resp.status_code == 200
assert resp.json()["portal_url"] == "https://billing.stripe.com/portal/test"
# ---------------------------------------------------------------------------
# Tests: webhook
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_webhook_not_configured(bill_client):
"""POST /api/billing/webhook returns 503 when billing not configured."""
with patch("app.api.billing.settings") as mock_settings:
mock_settings.stripe_secret_key = None
mock_settings.stripe_webhook_secret = None
resp = bill_client.post(
"/api/billing/webhook",
content=b"{}",
headers={"content-type": "application/json"},
)
assert resp.status_code == 503
@pytest.mark.integration
def test_webhook_invalid_signature(bill_client):
"""POST /api/billing/webhook returns 400 on invalid Stripe signature."""
import stripe
with (
patch("app.api.billing.settings") as mock_settings,
patch("stripe.Webhook.construct_event", side_effect=stripe.SignatureVerificationError("bad", "sig")),
):
mock_settings.stripe_secret_key = "sk_test_fake"
mock_settings.stripe_webhook_secret = "whsec_test"
resp = bill_client.post(
"/api/billing/webhook",
content=b'{"type":"test"}',
headers={"stripe-signature": "bad_sig", "content-type": "application/json"},
)
assert resp.status_code == 400
@pytest.mark.integration
def test_webhook_checkout_completed(bill_client, bill_session, starter_plan, user_profile):
"""POST /api/billing/webhook activates plan on checkout.session.completed."""
payload = json.dumps(
{
"type": "checkout.session.completed",
"data": {
"object": {
"customer": "cus_new",
"metadata": {
"docuelevate_user_id": "user1@example.com",
"plan_id": "starter",
"billing_cycle": "monthly",
},
}
},
}
).encode()
with (
patch("app.api.billing.settings") as mock_settings,
patch("stripe.Event.construct_from", return_value=json.loads(payload)),
):
mock_settings.stripe_secret_key = "sk_test_fake"
mock_settings.stripe_webhook_secret = None
resp = bill_client.post(
"/api/billing/webhook",
content=payload,
headers={"content-type": "application/json"},
)
assert resp.status_code == 200
bill_session.expire_all()
profile = bill_session.query(UserProfile).filter(UserProfile.user_id == "user1@example.com").first()
assert profile.subscription_tier == "starter"
@pytest.mark.integration
def test_webhook_subscription_deleted(bill_client, bill_session, user_profile):
"""POST /api/billing/webhook downgrades to free on subscription deleted."""
user_profile.stripe_customer_id = "cus_del"
user_profile.subscription_tier = "starter"
bill_session.commit()
payload = json.dumps(
{
"type": "customer.subscription.deleted",
"data": {"object": {"customer": "cus_del"}},
}
).encode()
with (
patch("app.api.billing.settings") as mock_settings,
patch("stripe.Event.construct_from", return_value=json.loads(payload)),
):
mock_settings.stripe_secret_key = "sk_test_fake"
mock_settings.stripe_webhook_secret = None
resp = bill_client.post(
"/api/billing/webhook",
content=payload,
headers={"content-type": "application/json"},
)
assert resp.status_code == 200
bill_session.expire_all()
profile = bill_session.query(UserProfile).filter(UserProfile.user_id == "user1@example.com").first()
assert profile.subscription_tier == "free"
# ---------------------------------------------------------------------------
# Unit tests: internal helpers
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_resolve_user_id_from_customer(bill_session, user_profile):
"""_resolve_user_id_from_customer returns user_id for known Stripe customer."""
user_profile.stripe_customer_id = "cus_known"
bill_session.commit()
result = _resolve_user_id_from_customer(bill_session, "cus_known")
assert result == "user1@example.com"
@pytest.mark.unit
def test_resolve_user_id_from_customer_unknown(bill_session):
"""_resolve_user_id_from_customer returns None for unknown customer."""
result = _resolve_user_id_from_customer(bill_session, "cus_unknown")
assert result is None
@pytest.mark.unit
def test_resolve_plan_id_from_price(bill_session, starter_plan):
"""_resolve_plan_id_from_price finds plan by monthly price ID."""
result = _resolve_plan_id_from_price(bill_session, "price_monthly_starter")
assert result == "starter"
@pytest.mark.unit
def test_resolve_plan_id_from_price_yearly(bill_session, starter_plan):
"""_resolve_plan_id_from_price finds plan by yearly price ID."""
result = _resolve_plan_id_from_price(bill_session, "price_yearly_starter")
assert result == "starter"
@pytest.mark.unit
def test_resolve_plan_id_from_price_unknown(bill_session):
"""_resolve_plan_id_from_price returns None for unknown price."""
result = _resolve_plan_id_from_price(bill_session, "price_unknown")
assert result is None
@pytest.mark.unit
def test_on_checkout_completed_missing_user_id(bill_session):
"""_on_checkout_completed does nothing when user_id is absent."""
data = {"metadata": {}, "customer": "cus_test"}
_on_checkout_completed(bill_session, data) # Should not raise
@pytest.mark.unit
def test_on_subscription_updated_no_items(bill_session, user_profile):
"""_on_subscription_updated does nothing when items list is empty."""
user_profile.stripe_customer_id = "cus_upd"
bill_session.commit()
data = {"customer": "cus_upd", "items": {"data": []}}
_on_subscription_updated(bill_session, data) # Should not raise
@pytest.mark.unit
def test_on_subscription_deleted_unknown_customer(bill_session):
"""_on_subscription_deleted does nothing for unknown customer."""
data = {"customer": "cus_nobody"}
_on_subscription_deleted(bill_session, data) # Should not raise
@pytest.mark.unit
def test_handle_stripe_event_unhandled_type(bill_session):
"""_handle_stripe_event logs but does not raise for unknown event types."""
event = {"type": "unknown.event.type", "data": {"object": {}}}
_handle_stripe_event(bill_session, event) # Should not raise
@pytest.mark.unit
def test_handle_stripe_event_payment_failed(bill_session):
"""_handle_stripe_event handles invoice.payment_failed without raising."""
event = {
"type": "invoice.payment_failed",
"data": {"object": {"customer": "cus_fail"}},
}
_handle_stripe_event(bill_session, event) # Should not raise
# ---------------------------------------------------------------------------
# Tests: billing success page
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_billing_success_page(bill_client):
"""GET /api/billing/success returns 200 for logged-in user."""
resp = bill_client.get("/api/billing/success")
assert resp.status_code == 200
assert b"subscription" in resp.content.lower() or b"success" in resp.content.lower()
+627
View File
@@ -0,0 +1,627 @@
"""Tests for local user authentication: signup, email verification, and password reset.
Covers:
- POST /api/auth/signup (success, disabled, SMTP missing, password mismatch, conflicts)
- GET /verify-email (valid token, invalid token, expired token)
- POST /api/auth/resend-verification
- POST /api/auth/request-password-reset
- POST /api/auth/reset-password
- GET /signup (page route)
- GET /verify-email-sent (page route)
- GET /reset-password (page route)
- app/utils/local_auth utility functions
- auth() login flow with LocalUser
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import LocalUser, UserProfile
from app.utils.local_auth import (
build_session_user,
generate_token,
hash_password,
is_token_expired,
verify_password,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
_TEST_DB_URL = "sqlite:///:memory:"
@pytest.fixture()
def la_engine():
"""In-memory SQLite engine for local auth tests."""
engine = create_engine(
_TEST_DB_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def la_session(la_engine):
"""DB session for one test."""
Session = sessionmaker(bind=la_engine)
session = Session()
yield session
session.close()
@pytest.fixture()
def la_client(la_engine):
"""TestClient with DB dependency overridden."""
from app.main import app
Session = sessionmaker(bind=la_engine)
def override_get_db():
db = Session()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
with TestClient(app, base_url="http://localhost", raise_server_exceptions=True) as client:
yield client
app.dependency_overrides.pop(get_db, None)
@pytest.fixture()
def active_user(la_session):
"""A fully active LocalUser in the DB."""
user = LocalUser(
email="active@example.com",
username="activeuser",
display_name="Active User",
hashed_password=hash_password("password123"),
is_active=True,
)
la_session.add(user)
la_session.add(UserProfile(user_id="active@example.com", display_name="Active User", onboarding_completed=True))
la_session.commit()
return user
@pytest.fixture()
def pending_user(la_session):
"""A LocalUser with a pending email verification token."""
token = "validtoken123"
user = LocalUser(
email="pending@example.com",
username="pendinguser",
hashed_password=hash_password("password123"),
is_active=False,
email_verification_token=token,
email_verification_sent_at=datetime.now(tz=timezone.utc),
)
la_session.add(user)
la_session.commit()
return user
# ---------------------------------------------------------------------------
# Unit tests: local_auth utilities
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_hash_and_verify_password():
"""hash_password produces a bcrypt hash that verify_password validates."""
plain = "super$ecret99"
hashed = hash_password(plain)
assert hashed != plain
assert verify_password(plain, hashed) is True
assert verify_password("wrong", hashed) is False
@pytest.mark.unit
def test_verify_password_bad_hash_returns_false():
"""verify_password returns False for a non-bcrypt string."""
assert verify_password("any", "notahash") is False
@pytest.mark.unit
def test_generate_token_unique():
"""generate_token returns distinct non-empty strings."""
tokens = {generate_token() for _ in range(10)}
assert len(tokens) == 10
for t in tokens:
assert len(t) > 20
@pytest.mark.unit
def test_is_token_expired_none():
"""None sent_at is treated as expired."""
assert is_token_expired(None) is True
@pytest.mark.unit
def test_is_token_expired_old():
"""Token sent more than 24 h ago is expired."""
old = datetime.now(tz=timezone.utc) - timedelta(hours=25)
assert is_token_expired(old) is True
@pytest.mark.unit
def test_is_token_expired_fresh():
"""Token sent recently is not expired."""
fresh = datetime.now(tz=timezone.utc) - timedelta(hours=1)
assert is_token_expired(fresh) is False
@pytest.mark.unit
def test_build_session_user():
"""build_session_user returns the expected dict structure."""
user = MagicMock()
user.email = "u@example.com"
user.username = "uname"
user.display_name = "Display Name"
user.is_admin = False
with patch("app.auth.get_gravatar_url", return_value="https://gravatar.com/test"):
result = build_session_user(user)
assert result["email"] == "u@example.com"
assert result["preferred_username"] == "uname"
assert result["name"] == "Display Name"
assert result["is_admin"] is False
assert result["auth_method"] == "local"
assert "picture" in result
# ---------------------------------------------------------------------------
# Integration tests: signup
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_signup_disabled(la_client):
"""POST /api/auth/signup returns 403 when allow_local_signup is False."""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.allow_local_signup = False
mock_settings.email_host = "smtp.example.com"
resp = la_client.post(
"/api/auth/signup",
json={
"email": "a@example.com",
"username": "auser",
"password": "password1",
"password_confirm": "password1",
},
)
assert resp.status_code == 403
@pytest.mark.integration
def test_signup_smtp_not_configured(la_client):
"""POST /api/auth/signup returns 503 when SMTP is not configured."""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.allow_local_signup = True
mock_settings.email_host = None
resp = la_client.post(
"/api/auth/signup",
json={
"email": "a@example.com",
"username": "auser",
"password": "password1",
"password_confirm": "password1",
},
)
assert resp.status_code == 503
@pytest.mark.integration
def test_signup_password_mismatch(la_client):
"""POST /api/auth/signup returns 422 when passwords do not match."""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.allow_local_signup = True
mock_settings.email_host = "smtp.example.com"
resp = la_client.post(
"/api/auth/signup",
json={
"email": "a@example.com",
"username": "auser",
"password": "password1",
"password_confirm": "different1",
},
)
assert resp.status_code == 422
@pytest.mark.integration
def test_signup_success(la_client):
"""POST /api/auth/signup creates user and returns 201."""
with (
patch("app.api.local_auth.settings") as mock_settings,
patch("app.api.local_auth.send_verification_email") as mock_send,
):
mock_settings.allow_local_signup = True
mock_settings.email_host = "smtp.example.com"
mock_settings.version = "test"
resp = la_client.post(
"/api/auth/signup",
json={
"email": "new@example.com",
"username": "newuser",
"password": "password1",
"password_confirm": "password1",
},
)
assert resp.status_code == 201
assert "Verification email sent" in resp.json()["message"]
mock_send.assert_called_once()
@pytest.mark.integration
def test_signup_duplicate_email(la_client, active_user):
"""POST /api/auth/signup returns 409 when email already registered."""
with (
patch("app.api.local_auth.settings") as mock_settings,
patch("app.api.local_auth.send_verification_email"),
):
mock_settings.allow_local_signup = True
mock_settings.email_host = "smtp.example.com"
resp = la_client.post(
"/api/auth/signup",
json={
"email": "active@example.com",
"username": "otheruser",
"password": "password1",
"password_confirm": "password1",
},
)
assert resp.status_code == 409
assert "Email" in resp.json()["detail"]
@pytest.mark.integration
def test_signup_duplicate_username(la_client, active_user):
"""POST /api/auth/signup returns 409 when username already taken."""
with (
patch("app.api.local_auth.settings") as mock_settings,
patch("app.api.local_auth.send_verification_email"),
):
mock_settings.allow_local_signup = True
mock_settings.email_host = "smtp.example.com"
resp = la_client.post(
"/api/auth/signup",
json={
"email": "different@example.com",
"username": "activeuser",
"password": "password1",
"password_confirm": "password1",
},
)
assert resp.status_code == 409
assert "Username" in resp.json()["detail"]
@pytest.mark.integration
def test_signup_smtp_failure_cleans_up(la_client, la_session):
"""POST /api/auth/signup cleans up user records if email send fails."""
with (
patch("app.api.local_auth.settings") as mock_settings,
patch("app.api.local_auth.send_verification_email", side_effect=RuntimeError("SMTP down")),
):
mock_settings.allow_local_signup = True
mock_settings.email_host = "smtp.example.com"
resp = la_client.post(
"/api/auth/signup",
json={
"email": "fail@example.com",
"username": "failuser",
"password": "password1",
"password_confirm": "password1",
},
)
assert resp.status_code == 503
# User should NOT exist in the DB
user = la_session.query(LocalUser).filter(LocalUser.email == "fail@example.com").first()
assert user is None
# ---------------------------------------------------------------------------
# Integration tests: email verification
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_verify_email_valid_token(la_client, pending_user):
"""GET /verify-email with valid token activates account and redirects."""
resp = la_client.get(
f"/verify-email?token={pending_user.email_verification_token}",
follow_redirects=False,
)
assert resp.status_code == 302
@pytest.mark.integration
def test_verify_email_invalid_token(la_client):
"""GET /verify-email with unknown token redirects to login with error."""
resp = la_client.get("/verify-email?token=doesnotexist", follow_redirects=False)
assert resp.status_code == 302
assert "/login" in resp.headers["location"]
@pytest.mark.integration
def test_verify_email_expired_token(la_client, la_session):
"""GET /verify-email with expired token redirects to login with error."""
old_time = datetime.now(tz=timezone.utc) - timedelta(hours=25)
user = LocalUser(
email="expired@example.com",
username="expireduser",
hashed_password=hash_password("password123"),
is_active=False,
email_verification_token="expiredtoken",
email_verification_sent_at=old_time,
)
la_session.add(user)
la_session.commit()
resp = la_client.get("/verify-email?token=expiredtoken", follow_redirects=False)
assert resp.status_code == 302
assert "/login" in resp.headers["location"]
# ---------------------------------------------------------------------------
# Integration tests: resend verification
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_resend_verification_always_200(la_client):
"""POST /api/auth/resend-verification returns 200 for unknown email."""
with patch("app.api.local_auth.send_verification_email"):
resp = la_client.post(
"/api/auth/resend-verification",
json={"email": "nobody@example.com"},
)
assert resp.status_code == 200
@pytest.mark.integration
def test_resend_verification_sends_email(la_client, pending_user):
"""POST /api/auth/resend-verification sends email for pending user."""
with patch("app.api.local_auth.send_verification_email") as mock_send:
resp = la_client.post(
"/api/auth/resend-verification",
json={"email": pending_user.email},
)
assert resp.status_code == 200
mock_send.assert_called_once()
# ---------------------------------------------------------------------------
# Integration tests: password reset
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_request_password_reset_always_200(la_client):
"""POST /api/auth/request-password-reset returns 200 for unknown email."""
with patch("app.api.local_auth.send_password_reset_email"):
resp = la_client.post(
"/api/auth/request-password-reset",
json={"email": "nobody@example.com"},
)
assert resp.status_code == 200
@pytest.mark.integration
def test_request_password_reset_sends_email(la_client, active_user):
"""POST /api/auth/request-password-reset sends email for known user."""
with patch("app.api.local_auth.send_password_reset_email") as mock_send:
resp = la_client.post(
"/api/auth/request-password-reset",
json={"email": active_user.email},
)
assert resp.status_code == 200
mock_send.assert_called_once()
@pytest.mark.integration
def test_reset_password_success(la_client, la_session):
"""POST /api/auth/reset-password updates password with valid token."""
token = "resettoken123"
user = LocalUser(
email="reset@example.com",
username="resetuser",
hashed_password=hash_password("oldpassword"),
is_active=True,
password_reset_token=token,
password_reset_sent_at=datetime.now(tz=timezone.utc),
)
la_session.add(user)
la_session.commit()
resp = la_client.post(
"/api/auth/reset-password",
json={
"token": token,
"new_password": "newpassword1",
"new_password_confirm": "newpassword1",
},
)
assert resp.status_code == 200
la_session.refresh(user)
assert verify_password("newpassword1", user.hashed_password)
assert user.password_reset_token is None
@pytest.mark.integration
def test_reset_password_invalid_token(la_client):
"""POST /api/auth/reset-password returns 400 for invalid token."""
resp = la_client.post(
"/api/auth/reset-password",
json={
"token": "badtoken",
"new_password": "newpassword1",
"new_password_confirm": "newpassword1",
},
)
assert resp.status_code == 400
@pytest.mark.integration
def test_reset_password_mismatch(la_client, la_session):
"""POST /api/auth/reset-password returns 422 when passwords do not match."""
token = "mismatchtoken"
user = LocalUser(
email="mismatch@example.com",
username="mismatchuser",
hashed_password=hash_password("old"),
is_active=True,
password_reset_token=token,
password_reset_sent_at=datetime.now(tz=timezone.utc),
)
la_session.add(user)
la_session.commit()
resp = la_client.post(
"/api/auth/reset-password",
json={
"token": token,
"new_password": "newpassword1",
"new_password_confirm": "different_pw",
},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# Integration tests: page routes
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_signup_page_disabled_redirects(la_client):
"""GET /signup redirects when allow_local_signup is False."""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.allow_local_signup = False
resp = la_client.get("/signup", follow_redirects=False)
assert resp.status_code == 302
assert "/login" in resp.headers["location"]
@pytest.mark.integration
def test_signup_page_enabled(la_client):
"""GET /signup returns 200 when allow_local_signup is True."""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.allow_local_signup = True
mock_settings.version = "test"
resp = la_client.get("/signup")
assert resp.status_code == 200
assert b"Create" in resp.content
@pytest.mark.integration
def test_verify_email_sent_page(la_client):
"""GET /verify-email-sent returns 200."""
resp = la_client.get("/verify-email-sent")
assert resp.status_code == 200
@pytest.mark.integration
def test_reset_password_page(la_client):
"""GET /reset-password returns 200."""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.version = "test"
resp = la_client.get("/reset-password?token=abc123")
assert resp.status_code == 200
assert b"password" in resp.content.lower()
# ---------------------------------------------------------------------------
# Integration tests: auth() login flow with LocalUser
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_local_login_success(la_session, active_user):
"""auth() with valid LocalUser credentials sets session and redirects."""
import asyncio
from unittest.mock import AsyncMock, MagicMock
from fastapi import Request
from app.auth import auth
mock_request = MagicMock(spec=Request)
mock_request.form = AsyncMock(return_value={"username": "activeuser", "password": "password123"})
mock_request.session = {}
result = asyncio.get_event_loop().run_until_complete(auth(mock_request, db=la_session))
assert result.status_code == 302
assert "user" in mock_request.session
assert mock_request.session["user"]["email"] == "active@example.com"
@pytest.mark.unit
def test_local_login_by_email(la_session, active_user):
"""auth() accepts email as username for LocalUser lookup."""
import asyncio
from unittest.mock import AsyncMock, MagicMock
from fastapi import Request
from app.auth import auth
mock_request = MagicMock(spec=Request)
mock_request.form = AsyncMock(return_value={"username": "active@example.com", "password": "password123"})
mock_request.session = {}
result = asyncio.get_event_loop().run_until_complete(auth(mock_request, db=la_session))
assert result.status_code == 302
assert "user" in mock_request.session
@pytest.mark.unit
def test_local_login_wrong_password(la_session, active_user):
"""auth() with wrong password redirects to login with error."""
import asyncio
from unittest.mock import AsyncMock, MagicMock
from fastapi import Request
from app.auth import auth
mock_request = MagicMock(spec=Request)
mock_request.form = AsyncMock(return_value={"username": "activeuser", "password": "wrongpassword"})
mock_request.session = {}
result = asyncio.get_event_loop().run_until_complete(auth(mock_request, db=la_session))
assert result.status_code == 302
assert "/login" in result.headers["location"]
assert "user" not in mock_request.session
@pytest.mark.unit
def test_local_login_unverified(la_session, pending_user):
"""auth() for unverified user redirects with verification message."""
import asyncio
from unittest.mock import AsyncMock, MagicMock
from fastapi import Request
from app.auth import auth
mock_request = MagicMock(spec=Request)
mock_request.form = AsyncMock(return_value={"username": "pendinguser", "password": "password123"})
mock_request.session = {}
result = asyncio.get_event_loop().run_until_complete(auth(mock_request, db=la_session))
assert result.status_code == 302
assert "verify" in result.headers["location"].lower()