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:
@@ -7,11 +7,12 @@ ready to serve traffic".
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import redis.asyncio as redis_asyncio
|
import redis.asyncio as redis_asyncio
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -23,8 +24,9 @@ from app.core.security import get_client_ip, is_localhost
|
|||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.enums import SystemSecretKey
|
from app.models.enums import SystemSecretKey
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.schemas.provisioning import PendingProvisioningCreate, PendingProvisioningResponse
|
||||||
from app.schemas.system_secret import SetSystemSecretRequest, SystemSecretStatus
|
from app.schemas.system_secret import SetSystemSecretRequest, SystemSecretStatus
|
||||||
from app.services import system_secret_service
|
from app.services import provisioning_service, system_secret_service
|
||||||
from app.services.enrichment_service import estimate_max_credits_per_company
|
from app.services.enrichment_service import estimate_max_credits_per_company
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -194,3 +196,39 @@ async def system_logs(
|
|||||||
"""Most-recent-first view into the application's live log stream (capped
|
"""Most-recent-first view into the application's live log stream (capped
|
||||||
at the last 500 entries app-wide, see `core/logging.py`)."""
|
at the last 500 entries app-wide, see `core/logging.py`)."""
|
||||||
return [LogEntryResponse(**entry) for entry in await get_recent_logs(settings, limit=100)]
|
return [LogEntryResponse(**entry) for entry in await get_recent_logs(settings, limit=100)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/pending-provisioning", response_model=list[PendingProvisioningResponse])
|
||||||
|
async def list_pending_provisioning(
|
||||||
|
db: AsyncSession = Depends(get_db), _admin: User = Depends(require_admin)
|
||||||
|
) -> list[PendingProvisioningResponse]:
|
||||||
|
"""Queued not-yet-existing accounts - see app.services.provisioning_service."""
|
||||||
|
return [
|
||||||
|
PendingProvisioningResponse.model_validate(p, from_attributes=True)
|
||||||
|
for p in await provisioning_service.list_pending(db)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/system/pending-provisioning",
|
||||||
|
response_model=PendingProvisioningResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_pending_provisioning(
|
||||||
|
payload: PendingProvisioningCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_admin: User = Depends(require_admin),
|
||||||
|
) -> PendingProvisioningResponse:
|
||||||
|
record = await provisioning_service.create_pending(
|
||||||
|
db, payload.email, payload.source_user_id, payload.make_admin
|
||||||
|
)
|
||||||
|
return PendingProvisioningResponse.model_validate(record, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/system/pending-provisioning/{pending_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_pending_provisioning(
|
||||||
|
pending_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_admin: User = Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
await provisioning_service.delete_pending(db, pending_id)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from app.models.notification_destination import ( # noqa: F401
|
|||||||
NotificationDestinationCompany,
|
NotificationDestinationCompany,
|
||||||
)
|
)
|
||||||
from app.models.password_history import PasswordHistoryEntry # noqa: F401
|
from app.models.password_history import PasswordHistoryEntry # noqa: F401
|
||||||
|
from app.models.pending_provisioning import PendingProvisioning # noqa: F401
|
||||||
from app.models.refresh_token import RefreshToken # noqa: F401
|
from app.models.refresh_token import RefreshToken # noqa: F401
|
||||||
from app.models.report import Report # noqa: F401
|
from app.models.report import Report # noqa: F401
|
||||||
from app.models.snapshot import Snapshot # noqa: F401
|
from app.models.snapshot import Snapshot # noqa: F401
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Lets an admin pre-configure what a not-yet-existing account should get
|
||||||
|
the moment someone verifies their email with a matching address - copies
|
||||||
|
of another user's per-user API keys and companies (with their full data:
|
||||||
|
sources, enrichment, reports, snapshots, etc.), an email notification
|
||||||
|
destination for the new account's own address, and optionally an admin
|
||||||
|
promotion. Built for demoing the app to people who don't have accounts
|
||||||
|
yet without hand-entering everything for them after the fact.
|
||||||
|
|
||||||
|
Applied at email-verification time, not raw registration, since
|
||||||
|
registering an email address doesn't prove you own it - see
|
||||||
|
auth_service.verify_email / provisioning_service.apply_if_pending.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class PendingProvisioning(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "pending_provisionings"
|
||||||
|
|
||||||
|
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||||
|
source_user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
make_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.pending_provisioning import PendingProvisioning
|
||||||
|
|
||||||
|
|
||||||
|
class PendingProvisioningRepository:
|
||||||
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def list_all(self) -> list[PendingProvisioning]:
|
||||||
|
result = await self.db.execute(select(PendingProvisioning))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_by_email(self, email: str) -> PendingProvisioning | None:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(PendingProvisioning).where(PendingProvisioning.email == email.lower())
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self, email: str, source_user_id: uuid.UUID, make_admin: bool
|
||||||
|
) -> PendingProvisioning:
|
||||||
|
record = PendingProvisioning(
|
||||||
|
email=email.lower(), source_user_id=source_user_id, make_admin=make_admin
|
||||||
|
)
|
||||||
|
self.db.add(record)
|
||||||
|
await self.db.flush()
|
||||||
|
return record
|
||||||
|
|
||||||
|
async def delete(self, record: PendingProvisioning) -> None:
|
||||||
|
await self.db.delete(record)
|
||||||
|
await self.db.flush()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class PendingProvisioningCreate(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
source_user_id: uuid.UUID
|
||||||
|
make_admin: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PendingProvisioningResponse(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
email: str
|
||||||
|
source_user_id: uuid.UUID
|
||||||
|
make_admin: bool
|
||||||
|
created_at: datetime
|
||||||
@@ -52,7 +52,7 @@ from app.schemas.auth import (
|
|||||||
TokenResponse,
|
TokenResponse,
|
||||||
VerifyEmailRequest,
|
VerifyEmailRequest,
|
||||||
)
|
)
|
||||||
from app.services import ip_throttle_service, security_email_service
|
from app.services import ip_throttle_service, provisioning_service, security_email_service
|
||||||
|
|
||||||
EMAIL_CODE_VALID_HOURS = 36
|
EMAIL_CODE_VALID_HOURS = 36
|
||||||
|
|
||||||
@@ -202,6 +202,11 @@ async def verify_email(db: AsyncSession, client_ip: str, payload: VerifyEmailReq
|
|||||||
|
|
||||||
await code_repo.mark_used(record)
|
await code_repo.mark_used(record)
|
||||||
user.email_verified = True
|
user.email_verified = True
|
||||||
|
# Verifying an email is the point at which the app can actually trust
|
||||||
|
# someone owns this address - if an admin queued this address up for
|
||||||
|
# pre-provisioning (see provisioning_service), this is where it fires,
|
||||||
|
# never at raw registration (which proves nothing about ownership).
|
||||||
|
await provisioning_service.apply_if_pending(db, user)
|
||||||
await ip_throttle_service.reset_on_success(db, client_ip, ThrottleAction.VERIFY_EMAIL_CODE)
|
await ip_throttle_service.reset_on_success(db, client_ip, ThrottleAction.VERIFY_EMAIL_CODE)
|
||||||
await UserSecurityEventRepository(db).create(
|
await UserSecurityEventRepository(db).create(
|
||||||
user_id=user.id, event_type=SecurityEventType.EMAIL_VERIFIED, ip_address=client_ip
|
user_id=user.id, event_type=SecurityEventType.EMAIL_VERIFIED, ip_address=client_ip
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""Pre-provisions a not-yet-existing account: the moment someone verifies
|
||||||
|
an email address an admin has queued up (see PendingProvisioning), they
|
||||||
|
get a deep copy of another user's per-user API keys and companies -
|
||||||
|
including every tab's worth of data (sources, source documents, monitoring
|
||||||
|
runs, reports, snapshots, detected changes, enrichment) - plus an email
|
||||||
|
notification destination for their own address linked to the copied
|
||||||
|
companies, and optionally an admin promotion.
|
||||||
|
|
||||||
|
Applied at email-verification time (see auth_service.verify_email), not
|
||||||
|
raw registration - registering an email doesn't prove ownership of it,
|
||||||
|
verifying does.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.errors import ConflictError, NotFoundError
|
||||||
|
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.enums import NotificationType, SeverityLevel
|
||||||
|
from app.models.monitor_configuration import MonitorConfiguration
|
||||||
|
from app.models.monitoring_run import MonitoringRun
|
||||||
|
from app.models.notification_destination import (
|
||||||
|
NotificationDestination,
|
||||||
|
NotificationDestinationCompany,
|
||||||
|
)
|
||||||
|
from app.models.pending_provisioning import PendingProvisioning
|
||||||
|
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.pending_provisioning_repository import PendingProvisioningRepository
|
||||||
|
from app.repositories.user_repository import UserRepository
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_row[ModelT](
|
||||||
|
model_cls: type[ModelT], source_row: Any, overrides: dict[str, Any]
|
||||||
|
) -> ModelT:
|
||||||
|
"""A new ORM instance of the same class, with every column value copied
|
||||||
|
from `source_row` except whatever `overrides` replaces (always at least
|
||||||
|
`id`, plus any foreign keys that need remapping to the new owner's
|
||||||
|
copies of their parents). Generic over every table involved so adding a
|
||||||
|
new company-related table later doesn't require touching this file."""
|
||||||
|
data = {
|
||||||
|
col.name: getattr(source_row, col.name) for col in model_cls.__table__.columns # type: ignore[attr-defined]
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return model_cls(**data)
|
||||||
|
|
||||||
|
|
||||||
|
async def _clone_company(
|
||||||
|
db: AsyncSession, source_company_id: uuid.UUID, target_user_id: uuid.UUID
|
||||||
|
) -> Company:
|
||||||
|
id_map: dict[uuid.UUID, uuid.UUID] = {}
|
||||||
|
|
||||||
|
def new_id(old_id: uuid.UUID) -> uuid.UUID:
|
||||||
|
if old_id not in id_map:
|
||||||
|
id_map[old_id] = uuid.uuid4()
|
||||||
|
return id_map[old_id]
|
||||||
|
|
||||||
|
company = await db.get(Company, source_company_id)
|
||||||
|
assert company is not None
|
||||||
|
|
||||||
|
new_company = _copy_row(Company, company, {"id": new_id(company.id), "user_id": target_user_id})
|
||||||
|
db.add(new_company)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
for alias in (
|
||||||
|
await db.execute(select(CompanyAlias).where(CompanyAlias.company_id == source_company_id))
|
||||||
|
).scalars():
|
||||||
|
db.add(_copy_row(CompanyAlias, alias, {"id": uuid.uuid4(), "company_id": new_company.id}))
|
||||||
|
|
||||||
|
for competitor in (
|
||||||
|
await db.execute(select(Competitor).where(Competitor.company_id == source_company_id))
|
||||||
|
).scalars():
|
||||||
|
db.add(
|
||||||
|
_copy_row(Competitor, competitor, {"id": uuid.uuid4(), "company_id": new_company.id})
|
||||||
|
)
|
||||||
|
|
||||||
|
config = (
|
||||||
|
await db.execute(
|
||||||
|
select(MonitorConfiguration).where(MonitorConfiguration.company_id == source_company_id)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if config is not None:
|
||||||
|
db.add(
|
||||||
|
_copy_row(
|
||||||
|
MonitorConfiguration, config, {"id": uuid.uuid4(), "company_id": new_company.id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
enrichment = (
|
||||||
|
await db.execute(
|
||||||
|
select(CompanyEnrichment).where(CompanyEnrichment.company_id == source_company_id)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if enrichment is not None:
|
||||||
|
db.add(
|
||||||
|
_copy_row(
|
||||||
|
CompanyEnrichment, enrichment, {"id": uuid.uuid4(), "company_id": new_company.id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
sources = (
|
||||||
|
(await db.execute(select(Source).where(Source.company_id == source_company_id)))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for source in sources:
|
||||||
|
db.add(_copy_row(Source, source, {"id": new_id(source.id), "company_id": new_company.id}))
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
for doc in (
|
||||||
|
await db.execute(
|
||||||
|
select(SourceDocument).where(SourceDocument.company_id == source_company_id)
|
||||||
|
)
|
||||||
|
).scalars():
|
||||||
|
db.add(
|
||||||
|
_copy_row(
|
||||||
|
SourceDocument,
|
||||||
|
doc,
|
||||||
|
{
|
||||||
|
"id": uuid.uuid4(),
|
||||||
|
"company_id": new_company.id,
|
||||||
|
"source_id": new_id(doc.source_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
runs = (
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(MonitoringRun).where(MonitoringRun.company_id == source_company_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for run in runs:
|
||||||
|
db.add(_copy_row(MonitoringRun, run, {"id": new_id(run.id), "company_id": new_company.id}))
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
for report in (
|
||||||
|
await db.execute(select(Report).where(Report.company_id == source_company_id))
|
||||||
|
).scalars():
|
||||||
|
db.add(
|
||||||
|
_copy_row(
|
||||||
|
Report,
|
||||||
|
report,
|
||||||
|
{
|
||||||
|
"id": uuid.uuid4(),
|
||||||
|
"company_id": new_company.id,
|
||||||
|
"monitoring_run_id": (
|
||||||
|
new_id(report.monitoring_run_id) if report.monitoring_run_id else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshots = (
|
||||||
|
(await db.execute(select(Snapshot).where(Snapshot.company_id == source_company_id)))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for snapshot in snapshots:
|
||||||
|
db.add(
|
||||||
|
_copy_row(
|
||||||
|
Snapshot,
|
||||||
|
snapshot,
|
||||||
|
{
|
||||||
|
"id": new_id(snapshot.id),
|
||||||
|
"company_id": new_company.id,
|
||||||
|
"source_id": new_id(snapshot.source_id) if snapshot.source_id else None,
|
||||||
|
"monitoring_run_id": (
|
||||||
|
new_id(snapshot.monitoring_run_id) if snapshot.monitoring_run_id else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
for change in (
|
||||||
|
await db.execute(
|
||||||
|
select(DetectedChange).where(DetectedChange.company_id == source_company_id)
|
||||||
|
)
|
||||||
|
).scalars():
|
||||||
|
db.add(
|
||||||
|
_copy_row(
|
||||||
|
DetectedChange,
|
||||||
|
change,
|
||||||
|
{
|
||||||
|
"id": uuid.uuid4(),
|
||||||
|
"company_id": new_company.id,
|
||||||
|
"source_id": new_id(change.source_id) if change.source_id else None,
|
||||||
|
"monitoring_run_id": (
|
||||||
|
new_id(change.monitoring_run_id) if change.monitoring_run_id else None
|
||||||
|
),
|
||||||
|
"previous_snapshot_id": (
|
||||||
|
new_id(change.previous_snapshot_id) if change.previous_snapshot_id else None
|
||||||
|
),
|
||||||
|
"current_snapshot_id": (
|
||||||
|
new_id(change.current_snapshot_id) if change.current_snapshot_id else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_company
|
||||||
|
|
||||||
|
|
||||||
|
async def _copy_api_keys(
|
||||||
|
db: AsyncSession, source_user_id: uuid.UUID, target_user_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
keys = (
|
||||||
|
(await db.execute(select(UserApiKey).where(UserApiKey.user_id == source_user_id)))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for key in keys:
|
||||||
|
db.add(_copy_row(UserApiKey, key, {"id": uuid.uuid4(), "user_id": target_user_id}))
|
||||||
|
|
||||||
|
|
||||||
|
async def create_pending(
|
||||||
|
db: AsyncSession, email: str, source_user_id: uuid.UUID, make_admin: bool
|
||||||
|
) -> PendingProvisioning:
|
||||||
|
if await UserRepository(db).get_by_email(email) is not None:
|
||||||
|
raise ConflictError("An account with this email already exists")
|
||||||
|
if await PendingProvisioningRepository(db).get_by_email(email) is not None:
|
||||||
|
raise ConflictError("This email is already queued for provisioning")
|
||||||
|
record = await PendingProvisioningRepository(db).create(email, source_user_id, make_admin)
|
||||||
|
await db.commit()
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def list_pending(db: AsyncSession) -> list[PendingProvisioning]:
|
||||||
|
return await PendingProvisioningRepository(db).list_all()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_pending(db: AsyncSession, pending_id: uuid.UUID) -> None:
|
||||||
|
repo = PendingProvisioningRepository(db)
|
||||||
|
match = next((p for p in await repo.list_all() if p.id == pending_id), None)
|
||||||
|
if match is None:
|
||||||
|
raise NotFoundError("Pending provisioning entry not found")
|
||||||
|
await repo.delete(match)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_if_pending(db: AsyncSession, new_user: User) -> bool:
|
||||||
|
"""Called right after a fresh account's email gets verified. Returns
|
||||||
|
True if a pending provisioning entry matched and was applied (and
|
||||||
|
consumed - a pending entry only ever fires once)."""
|
||||||
|
pending = await PendingProvisioningRepository(db).get_by_email(new_user.email)
|
||||||
|
if pending is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await _copy_api_keys(db, pending.source_user_id, new_user.id)
|
||||||
|
|
||||||
|
source_companies = (
|
||||||
|
(await db.execute(select(Company).where(Company.user_id == pending.source_user_id)))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
new_company_ids = [
|
||||||
|
(await _clone_company(db, company.id, new_user.id)).id for company in source_companies
|
||||||
|
]
|
||||||
|
|
||||||
|
if new_company_ids:
|
||||||
|
destination = NotificationDestination(
|
||||||
|
user_id=new_user.id,
|
||||||
|
type=NotificationType.EMAIL,
|
||||||
|
destination_value=new_user.email,
|
||||||
|
verified=False,
|
||||||
|
enabled=True,
|
||||||
|
minimum_severity=SeverityLevel.MEDIUM,
|
||||||
|
)
|
||||||
|
db.add(destination)
|
||||||
|
await db.flush()
|
||||||
|
for company_id in new_company_ids:
|
||||||
|
db.add(
|
||||||
|
NotificationDestinationCompany(destination_id=destination.id, company_id=company_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if pending.make_admin:
|
||||||
|
new_user.is_admin = True
|
||||||
|
|
||||||
|
await PendingProvisioningRepository(db).delete(pending)
|
||||||
|
return True
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""add pending_provisionings table
|
||||||
|
|
||||||
|
Revision ID: 2c2a563a5edb
|
||||||
|
Revises: 9e6c80c11da7
|
||||||
|
Create Date: 2026-08-06 22:22:22.348339
|
||||||
|
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = '2c2a563a5edb'
|
||||||
|
down_revision: str | None = '9e6c80c11da7'
|
||||||
|
branch_labels: Sequence[str] | str | None = None
|
||||||
|
depends_on: Sequence[str] | str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Autogenerate also proposed adding a redundant UNIQUE constraint on the
|
||||||
|
# `id` column of every other existing table (a cosmetic artifact of how
|
||||||
|
# this project's earlier migrations declared unnamed unique constraints,
|
||||||
|
# unrelated to this change) - stripped out, keeping only the new table.
|
||||||
|
op.create_table('pending_provisionings',
|
||||||
|
sa.Column('email', sa.String(length=320), nullable=False),
|
||||||
|
sa.Column('source_user_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('make_admin', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['source_user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_pending_provisionings_email'), 'pending_provisionings', ['email'], unique=True)
|
||||||
|
op.create_index(op.f('ix_pending_provisionings_source_user_id'), 'pending_provisionings', ['source_user_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f('ix_pending_provisionings_source_user_id'), table_name='pending_provisionings')
|
||||||
|
op.drop_index(op.f('ix_pending_provisionings_email'), table_name='pending_provisionings')
|
||||||
|
op.drop_table('pending_provisionings')
|
||||||
@@ -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']}"}
|
||||||
Reference in New Issue
Block a user