Add pending account provisioning for demoing to not-yet-existing accounts

An admin can now queue up an email address in advance (POST/GET/DELETE
/system/pending-provisioning) with a source user and an optional admin
flag. The moment that email actually verifies a real account - not raw
registration, which proves nothing about ownership - it gets a deep copy
of the source user's per-user API keys and every company they own
(company profile, aliases, competitors, monitor config, sources, source
documents, monitoring runs, reports, snapshots, detected changes, and
enrichment - not just the company row), plus an email notification
destination for its own address linked to the copied companies.

The clone logic (_copy_row/_clone_company in provisioning_service.py) is
generic over every table it touches via column introspection, so it
doesn't need hand-maintained field lists per model.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-06 18:47:29 -04:00
co-authored by Claude Sonnet 5
parent 18305b545c
commit 56b4a5404e
9 changed files with 904 additions and 3 deletions
+429
View File
@@ -0,0 +1,429 @@
"""Pending account provisioning: an admin queues up an email in advance,
and the moment that email actually verifies a real account, it gets a
deep copy of another user's API keys and companies (every related table,
not just the company row itself), a notification destination for its own
address, and optionally an admin promotion. See provisioning_service.py."""
from __future__ import annotations
import asyncio
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import select
from app.core.errors import ConflictError, NotFoundError
from app.core.security import hash_email_code, hash_password
from app.db.session import get_sessionmaker
from app.models.company import Company, CompanyAlias, Competitor
from app.models.company_enrichment import CompanyEnrichment
from app.models.detected_change import DetectedChange
from app.models.email_code import EmailCode
from app.models.enums import (
ApiKeyProvider,
ChangeStatus,
ChangeType,
CompanyStatus,
EmailCodePurpose,
EnrichmentStatus,
MonitoringFrequency,
MonitoringRunStatus,
MonitoringRunTrigger,
ReportType,
SeverityLevel,
SourceStatus,
SourceType,
)
from app.models.monitor_configuration import MonitorConfiguration
from app.models.monitoring_run import MonitoringRun
from app.models.report import Report
from app.models.snapshot import Snapshot
from app.models.source import Source
from app.models.source_document import SourceDocument
from app.models.user import User
from app.models.user_api_key import UserApiKey
from app.repositories.notification_destination_repository import (
NotificationDestinationRepository,
)
from app.repositories.pending_provisioning_repository import PendingProvisioningRepository
from app.repositories.user_repository import UserRepository
from app.services import provisioning_service
async def _make_user(db_session, *, email: str | None = None, is_admin: bool = False) -> User:
user = User(
id=uuid.uuid4(),
email=(email or f"user-{uuid.uuid4().hex[:10]}@example.com").lower(),
password_hash=hash_password("correct-horse-1"),
display_name="Test User",
timezone="UTC",
is_admin=is_admin,
email_verified=True,
)
db_session.add(user)
await db_session.flush()
return user
async def _build_full_company(db_session, owner: User) -> Company:
"""One company with at least one row in every table _clone_company
touches, so the deep-clone test actually exercises every FK remap."""
company = Company(
user_id=owner.id,
name="Acme Corp",
slug=f"acme-{uuid.uuid4().hex[:6]}",
official_website="https://acme.example.com",
status=CompanyStatus.ACTIVE,
)
db_session.add(company)
await db_session.flush()
db_session.add(CompanyAlias(company_id=company.id, alias="Acme"))
db_session.add(Competitor(company_id=company.id, name="Rival Co"))
db_session.add(
MonitorConfiguration(
company_id=company.id, frequency_type=MonitoringFrequency.WEEKLY, enabled=True
)
)
db_session.add(
CompanyEnrichment(
company_id=company.id,
status=EnrichmentStatus.COMPLETE,
data={"products": [{"name": "Widget"}]},
errors={},
credits_spent=5,
)
)
source = Source(
company_id=company.id,
source_type=SourceType.WEBSITE,
name="Homepage",
status=SourceStatus.ACTIVE,
)
db_session.add(source)
await db_session.flush()
db_session.add(
SourceDocument(
source_id=source.id,
company_id=company.id,
url="https://acme.example.com/page",
canonical_url="https://acme.example.com/page",
title="Homepage",
content_text="Hello world",
content_hash="abc123",
retrieved_date=datetime.now(UTC),
extraction_method="static_html",
)
)
run = MonitoringRun(
company_id=company.id,
trigger_type=MonitoringRunTrigger.MANUAL,
status=MonitoringRunStatus.SUCCESSFUL,
)
db_session.add(run)
await db_session.flush()
db_session.add(
Report(
company_id=company.id,
monitoring_run_id=run.id,
report_type=ReportType.MANUAL,
title="Acme Report",
executive_summary="Summary",
structured_report={"executive_summary": "Summary"},
markdown_content="# Acme Report",
model_provider="mock",
model_name="mock",
)
)
snapshot = Snapshot(
company_id=company.id,
source_id=source.id,
monitoring_run_id=run.id,
snapshot_type="structured",
hash="snaphash",
)
db_session.add(snapshot)
await db_session.flush()
db_session.add(
DetectedChange(
company_id=company.id,
source_id=source.id,
monitoring_run_id=run.id,
previous_snapshot_id=snapshot.id,
current_snapshot_id=snapshot.id,
change_type=ChangeType.CONTENT_MODIFIED,
severity=SeverityLevel.LOW,
status=ChangeStatus.NEW,
summary="Something changed",
confidence_score=0.5,
significance_score=0.5,
)
)
db_session.add(
UserApiKey(
user_id=owner.id, provider=ApiKeyProvider.ANTHROPIC, encrypted_key="encrypted-blob"
)
)
await db_session.commit()
return company
@pytest.mark.asyncio
async def test_create_pending_rejects_an_email_that_already_has_an_account(db_session, settings):
owner = await _make_user(db_session)
existing = await _make_user(db_session, email="[email protected]")
with pytest.raises(ConflictError):
await provisioning_service.create_pending(db_session, existing.email, owner.id, False)
@pytest.mark.asyncio
async def test_create_pending_rejects_a_duplicate_pending_email(db_session, settings):
owner = await _make_user(db_session)
email = f"pending-{uuid.uuid4().hex[:10]}@example.com"
await provisioning_service.create_pending(db_session, email, owner.id, False)
with pytest.raises(ConflictError):
await provisioning_service.create_pending(db_session, email, owner.id, False)
@pytest.mark.asyncio
async def test_apply_if_pending_is_a_no_op_without_a_matching_entry(db_session, settings):
new_user = await _make_user(db_session, email="[email protected]")
applied = await provisioning_service.apply_if_pending(db_session, new_user)
assert applied is False
@pytest.mark.asyncio
async def test_apply_if_pending_deep_clones_everything_and_is_consumed_once(db_session, settings):
owner = await _make_user(db_session)
company = await _build_full_company(db_session, owner)
email = f"invitee-{uuid.uuid4().hex[:10]}@example.com"
await provisioning_service.create_pending(db_session, email, owner.id, make_admin=True)
new_user = await _make_user(db_session, email=email)
applied = await provisioning_service.apply_if_pending(db_session, new_user)
await db_session.commit()
assert applied is True
assert new_user.is_admin is True
# The pending entry only ever fires once.
assert await PendingProvisioningRepository(db_session).get_by_email(email) is None
applied_again = await provisioning_service.apply_if_pending(db_session, new_user)
assert applied_again is False
new_company = (
await db_session.execute(select(Company).where(Company.user_id == new_user.id))
).scalar_one()
assert new_company.id != company.id
assert new_company.name == "Acme Corp"
assert (
await db_session.execute(
select(CompanyAlias).where(CompanyAlias.company_id == new_company.id)
)
).scalar_one().alias == "Acme"
assert (
await db_session.execute(select(Competitor).where(Competitor.company_id == new_company.id))
).scalar_one().name == "Rival Co"
assert (
(
await db_session.execute(
select(MonitorConfiguration).where(
MonitorConfiguration.company_id == new_company.id
)
)
)
.scalar_one()
.enabled
)
new_source = (
await db_session.execute(select(Source).where(Source.company_id == new_company.id))
).scalar_one()
assert (
new_source.id
!= (await db_session.execute(select(Source).where(Source.company_id == company.id)))
.scalar_one()
.id
)
new_doc = (
await db_session.execute(
select(SourceDocument).where(SourceDocument.company_id == new_company.id)
)
).scalar_one()
assert new_doc.source_id == new_source.id # remapped to the CLONED source, not the original
new_run = (
await db_session.execute(
select(MonitoringRun).where(MonitoringRun.company_id == new_company.id)
)
).scalar_one()
new_report = (
await db_session.execute(select(Report).where(Report.company_id == new_company.id))
).scalar_one()
assert new_report.monitoring_run_id == new_run.id
new_snapshot = (
await db_session.execute(select(Snapshot).where(Snapshot.company_id == new_company.id))
).scalar_one()
assert new_snapshot.source_id == new_source.id
assert new_snapshot.monitoring_run_id == new_run.id
new_change = (
await db_session.execute(
select(DetectedChange).where(DetectedChange.company_id == new_company.id)
)
).scalar_one()
assert new_change.previous_snapshot_id == new_snapshot.id
assert new_change.current_snapshot_id == new_snapshot.id
assert new_change.source_id == new_source.id
assert new_change.monitoring_run_id == new_run.id
new_enrichment = (
await db_session.execute(
select(CompanyEnrichment).where(CompanyEnrichment.company_id == new_company.id)
)
).scalar_one()
assert new_enrichment.data == {"products": [{"name": "Widget"}]}
new_key = (
await db_session.execute(select(UserApiKey).where(UserApiKey.user_id == new_user.id))
).scalar_one()
assert new_key.provider == ApiKeyProvider.ANTHROPIC
assert new_key.encrypted_key == "encrypted-blob" # copied as-is, not re-encrypted
# The original owner's data must be completely untouched.
original_source = (
await db_session.execute(select(Source).where(Source.company_id == company.id))
).scalar_one()
assert original_source.id != new_source.id
destination = (await NotificationDestinationRepository(db_session).list_for_user(new_user.id))[
0
]
assert destination.destination_value == email
assert destination.company_links[0].company_id == new_company.id
@pytest.mark.asyncio
async def test_apply_if_pending_without_make_admin_leaves_user_a_regular_account(
db_session, settings
):
owner = await _make_user(db_session)
await _build_full_company(db_session, owner)
email = f"invitee-{uuid.uuid4().hex[:10]}@example.com"
await provisioning_service.create_pending(db_session, email, owner.id, make_admin=False)
new_user = await _make_user(db_session, email=email)
await provisioning_service.apply_if_pending(db_session, new_user)
assert new_user.is_admin is False
@pytest.mark.asyncio
async def test_delete_pending_removes_an_unconsumed_entry(db_session, settings):
owner = await _make_user(db_session)
email = f"pending-{uuid.uuid4().hex[:10]}@example.com"
record = await provisioning_service.create_pending(db_session, email, owner.id, False)
await provisioning_service.delete_pending(db_session, record.id)
assert await PendingProvisioningRepository(db_session).get_by_email(email) is None
@pytest.mark.asyncio
async def test_delete_pending_404s_for_an_unknown_id(db_session, settings):
with pytest.raises(NotFoundError):
await provisioning_service.delete_pending(db_session, uuid.uuid4())
def test_verify_email_applies_a_matching_pending_entry_end_to_end(client, settings):
"""The verify-email endpoint, not registration, is what actually
triggers provisioning - see auth_service.verify_email's docstring."""
owner_email = f"owner-{uuid.uuid4().hex[:10]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": owner_email, "password": "correct-horse-1", "display_name": "Owner"},
)
invitee_email = f"invitee-{uuid.uuid4().hex[:10]}@example.com"
async def _setup() -> None:
async with get_sessionmaker()() as db:
owner = await UserRepository(db).get_by_email(owner_email)
await _build_full_company(db, owner)
await provisioning_service.create_pending(db, invitee_email, owner.id, make_admin=True)
asyncio.run(_setup())
register_resp = client.post(
"/api/v1/auth/register",
json={"email": invitee_email, "password": "correct-horse-1", "display_name": "Invitee"},
)
assert register_resp.status_code == 201
assert register_resp.json()["is_admin"] is False # not yet - only verifying triggers it
code = "424242"
async def _issue_code() -> None:
async with get_sessionmaker()() as db:
db.add(
EmailCode(
user_id=uuid.UUID(register_resp.json()["id"]),
purpose=EmailCodePurpose.VERIFY_EMAIL,
code_hash=hash_email_code(code),
expires_at=datetime.now(UTC) + timedelta(hours=1),
)
)
await db.commit()
asyncio.run(_issue_code())
verify_resp = client.post(
"/api/v1/auth/verify-email", json={"email": invitee_email, "code": code}
)
assert verify_resp.status_code == 204
login = client.post(
"/api/v1/auth/login", json={"email": invitee_email, "password": "correct-horse-1"}
).json()
headers = {"Authorization": f"Bearer {login['access_token']}"}
me = client.get("/api/v1/auth/me", headers=headers).json()
assert me["is_admin"] is True
companies = client.get("/api/v1/companies", headers=headers).json()
assert len(companies) == 1
assert companies[0]["name"] == "Acme Corp"
def test_pending_provisioning_admin_endpoints_require_admin(client):
headers = _register_and_login_plain(client)
resp = client.get("/api/v1/system/pending-provisioning", headers=headers)
assert resp.status_code == 403
def _register_and_login_plain(client) -> dict[str, str]:
email = f"plain-{uuid.uuid4().hex[:10]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Plain"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}