first implementation

This commit is contained in:
counterweight 2025-12-20 11:12:11 +01:00
parent 79458bcba4
commit 870804e7b9
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
24 changed files with 5485 additions and 184 deletions

60
backend/invite_utils.py Normal file
View file

@ -0,0 +1,60 @@
"""Utilities for invite code generation and validation."""
import random
from pathlib import Path
# Load BIP39 words from file
_WORDS_FILE = Path(__file__).parent / "words.txt"
with open(_WORDS_FILE) as f:
BIP39_WORDS = [line.strip() for line in f if line.strip()]
assert len(BIP39_WORDS) == 2048, f"Expected 2048 BIP39 words, got {len(BIP39_WORDS)}"
def generate_invite_identifier() -> str:
"""
Generate a unique invite identifier.
Format: word1-word2-NN where:
- word1, word2 are random BIP39 words
- NN is a two-digit number (00-99)
Returns lowercase identifier.
"""
word1 = random.choice(BIP39_WORDS)
word2 = random.choice(BIP39_WORDS)
number = random.randint(0, 99)
return f"{word1}-{word2}-{number:02d}"
def normalize_identifier(identifier: str) -> str:
"""
Normalize an invite identifier for comparison/lookup.
- Converts to lowercase
- Strips whitespace
"""
return identifier.strip().lower()
def is_valid_identifier_format(identifier: str) -> bool:
"""
Check if an identifier has valid format (word-word-NN).
Does NOT check if words are valid BIP39 words.
"""
parts = identifier.split("-")
if len(parts) != 3:
return False
word1, word2, number = parts
# Check words are non-empty
if not word1 or not word2:
return False
# Check number is two digits
if len(number) != 2 or not number.isdigit():
return False
return True

View file

@ -1,10 +1,10 @@
from contextlib import asynccontextmanager
from datetime import datetime
from datetime import datetime, UTC
from typing import Callable, Generic, TypeVar
from fastapi import FastAPI, Depends, HTTPException, Response, status, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from pydantic import BaseModel, EmailStr
from sqlalchemy import select, func, desc
from sqlalchemy.ext.asyncio import AsyncSession
@ -23,8 +23,9 @@ from auth import (
build_user_response,
)
from database import engine, get_db, Base
from models import Counter, User, SumRecord, CounterRecord, Permission, Role, ROLE_REGULAR
from models import Counter, User, SumRecord, CounterRecord, Permission, Role, ROLE_REGULAR, Invite, InviteStatus
from validation import validate_profile_fields
from invite_utils import generate_invite_identifier, normalize_identifier
R = TypeVar("R", bound=BaseModel)
@ -98,13 +99,88 @@ async def get_default_role(db: AsyncSession) -> Role | None:
return result.scalar_one_or_none()
# Invite check endpoint (public)
class InviteCheckResponse(BaseModel):
"""Response for invite check endpoint."""
valid: bool
status: str | None = None
error: str | None = None
@app.get("/api/invites/{identifier}/check", response_model=InviteCheckResponse)
async def check_invite(
identifier: str,
db: AsyncSession = Depends(get_db),
):
"""Check if an invite is valid and can be used for signup."""
normalized = normalize_identifier(identifier)
result = await db.execute(
select(Invite).where(Invite.identifier == normalized)
)
invite = result.scalar_one_or_none()
if not invite:
return InviteCheckResponse(valid=False, error="Invite not found")
if invite.status == InviteStatus.SPENT:
return InviteCheckResponse(
valid=False,
status=invite.status.value,
error="This invite has already been used"
)
if invite.status == InviteStatus.REVOKED:
return InviteCheckResponse(
valid=False,
status=invite.status.value,
error="This invite has been revoked"
)
return InviteCheckResponse(valid=True, status=invite.status.value)
# Auth endpoints
class RegisterWithInvite(BaseModel):
"""Request model for registration with invite."""
email: EmailStr
password: str
invite_identifier: str
@app.post("/api/auth/register", response_model=UserResponse)
async def register(
user_data: UserCreate,
user_data: RegisterWithInvite,
response: Response,
db: AsyncSession = Depends(get_db),
):
"""Register a new user using an invite code."""
# Validate invite
normalized_identifier = normalize_identifier(user_data.invite_identifier)
result = await db.execute(
select(Invite).where(Invite.identifier == normalized_identifier)
)
invite = result.scalar_one_or_none()
if not invite:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid invite code",
)
if invite.status == InviteStatus.SPENT:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This invite has already been used",
)
if invite.status == InviteStatus.REVOKED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This invite has been revoked",
)
# Check email not already taken
existing_user = await get_user_by_email(db, user_data.email)
if existing_user:
raise HTTPException(
@ -112,17 +188,26 @@ async def register(
detail="Email already registered",
)
# Create user with godfather
user = User(
email=user_data.email,
hashed_password=get_password_hash(user_data.password),
godfather_id=invite.godfather_id,
)
# Assign default role if it exists
# Assign default role
default_role = await get_default_role(db)
if default_role:
user.roles.append(default_role)
db.add(user)
await db.flush() # Get user ID
# Mark invite as spent
invite.status = InviteStatus.SPENT
invite.used_by_id = user.id
invite.spent_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
@ -333,6 +418,7 @@ class ProfileResponse(BaseModel):
telegram: str | None
signal: str | None
nostr_npub: str | None
godfather_email: str | None = None
class ProfileUpdate(BaseModel):
@ -358,13 +444,22 @@ async def require_regular_user(
@app.get("/api/profile", response_model=ProfileResponse)
async def get_profile(
current_user: User = Depends(require_regular_user),
db: AsyncSession = Depends(get_db),
):
"""Get the current user's profile (contact details)."""
"""Get the current user's profile (contact details and godfather)."""
godfather_email = None
if current_user.godfather_id:
result = await db.execute(
select(User.email).where(User.id == current_user.godfather_id)
)
godfather_email = result.scalar_one_or_none()
return ProfileResponse(
contact_email=current_user.contact_email,
telegram=current_user.telegram,
signal=current_user.signal,
nostr_npub=current_user.nostr_npub,
godfather_email=godfather_email,
)
@ -398,9 +493,274 @@ async def update_profile(
await db.commit()
await db.refresh(current_user)
# Get godfather email if set
godfather_email = None
if current_user.godfather_id:
gf_result = await db.execute(
select(User.email).where(User.id == current_user.godfather_id)
)
godfather_email = gf_result.scalar_one_or_none()
return ProfileResponse(
contact_email=current_user.contact_email,
telegram=current_user.telegram,
signal=current_user.signal,
nostr_npub=current_user.nostr_npub,
godfather_email=godfather_email,
)
# Invite endpoints
class InviteCreate(BaseModel):
"""Request model for creating an invite."""
godfather_id: int
class InviteResponse(BaseModel):
"""Response model for invite data."""
id: int
identifier: str
godfather_id: int
godfather_email: str
status: str
used_by_id: int | None
used_by_email: str | None
created_at: datetime
spent_at: datetime | None
revoked_at: datetime | None
@app.post("/api/admin/invites", response_model=InviteResponse)
async def create_invite(
data: InviteCreate,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
):
"""Create a new invite for a specified godfather user."""
# Validate godfather exists
result = await db.execute(select(User).where(User.id == data.godfather_id))
godfather = result.scalar_one_or_none()
if not godfather:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Godfather user not found",
)
# Generate unique identifier
identifier = generate_invite_identifier()
# Create invite
invite = Invite(
identifier=identifier,
godfather_id=godfather.id,
status=InviteStatus.READY,
)
db.add(invite)
await db.commit()
await db.refresh(invite)
return InviteResponse(
id=invite.id,
identifier=invite.identifier,
godfather_id=invite.godfather_id,
godfather_email=godfather.email,
status=invite.status.value,
used_by_id=invite.used_by_id,
used_by_email=None,
created_at=invite.created_at,
spent_at=invite.spent_at,
revoked_at=invite.revoked_at,
)
class UserInviteResponse(BaseModel):
"""Response model for a user's invite (simpler than admin view)."""
id: int
identifier: str
status: str
used_by_email: str | None
created_at: datetime
spent_at: datetime | None
@app.get("/api/invites", response_model=list[UserInviteResponse])
async def get_my_invites(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.VIEW_OWN_INVITES)),
):
"""Get all invites owned by the current user."""
result = await db.execute(
select(Invite)
.where(Invite.godfather_id == current_user.id)
.order_by(desc(Invite.created_at))
)
invites = result.scalars().all()
responses = []
for invite in invites:
used_by_email = None
if invite.used_by_id:
# Fetch the user who used this invite
user_result = await db.execute(
select(User.email).where(User.id == invite.used_by_id)
)
used_by_email = user_result.scalar_one_or_none()
responses.append(UserInviteResponse(
id=invite.id,
identifier=invite.identifier,
status=invite.status.value,
used_by_email=used_by_email,
created_at=invite.created_at,
spent_at=invite.spent_at,
))
return responses
# Admin Invite Management
PaginatedInviteRecords = PaginatedResponse[InviteResponse]
class AdminUserResponse(BaseModel):
"""Minimal user info for admin dropdowns."""
id: int
email: str
@app.get("/api/admin/users", response_model=list[AdminUserResponse])
async def list_users_for_admin(
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
):
"""List all users for admin dropdowns (invite creation, etc.)."""
result = await db.execute(select(User.id, User.email).order_by(User.email))
users = result.all()
return [AdminUserResponse(id=u.id, email=u.email) for u in users]
@app.get("/api/admin/invites", response_model=PaginatedInviteRecords)
async def list_all_invites(
page: int = Query(1, ge=1),
per_page: int = Query(10, ge=1, le=100),
status_filter: str | None = Query(None, alias="status", description="Filter by status: ready, spent, revoked"),
godfather_id: int | None = Query(None, description="Filter by godfather user ID"),
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
):
"""List all invites with optional filtering and pagination."""
# Build query
query = select(Invite)
count_query = select(func.count(Invite.id))
# Apply filters
if status_filter:
try:
status_enum = InviteStatus(status_filter)
query = query.where(Invite.status == status_enum)
count_query = count_query.where(Invite.status == status_enum)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid status: {status_filter}. Must be ready, spent, or revoked",
)
if godfather_id:
query = query.where(Invite.godfather_id == godfather_id)
count_query = count_query.where(Invite.godfather_id == godfather_id)
# Get total count
count_result = await db.execute(count_query)
total = count_result.scalar() or 0
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
# Get paginated invites
offset = (page - 1) * per_page
query = query.order_by(desc(Invite.created_at)).offset(offset).limit(per_page)
result = await db.execute(query)
invites = result.scalars().all()
# Build responses with user emails
records = []
for invite in invites:
# Get godfather email
gf_result = await db.execute(
select(User.email).where(User.id == invite.godfather_id)
)
godfather_email = gf_result.scalar_one()
# Get used_by email if applicable
used_by_email = None
if invite.used_by_id:
ub_result = await db.execute(
select(User.email).where(User.id == invite.used_by_id)
)
used_by_email = ub_result.scalar_one_or_none()
records.append(InviteResponse(
id=invite.id,
identifier=invite.identifier,
godfather_id=invite.godfather_id,
godfather_email=godfather_email,
status=invite.status.value,
used_by_id=invite.used_by_id,
used_by_email=used_by_email,
created_at=invite.created_at,
spent_at=invite.spent_at,
revoked_at=invite.revoked_at,
))
return PaginatedInviteRecords(
records=records,
total=total,
page=page,
per_page=per_page,
total_pages=total_pages,
)
@app.post("/api/admin/invites/{invite_id}/revoke", response_model=InviteResponse)
async def revoke_invite(
invite_id: int,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
):
"""Revoke an invite. Only READY invites can be revoked."""
result = await db.execute(select(Invite).where(Invite.id == invite_id))
invite = result.scalar_one_or_none()
if not invite:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invite not found",
)
if invite.status != InviteStatus.READY:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot revoke invite with status '{invite.status.value}'. Only READY invites can be revoked.",
)
invite.status = InviteStatus.REVOKED
invite.revoked_at = datetime.now(UTC)
await db.commit()
await db.refresh(invite)
# Get godfather email
gf_result = await db.execute(
select(User.email).where(User.id == invite.godfather_id)
)
godfather_email = gf_result.scalar_one()
return InviteResponse(
id=invite.id,
identifier=invite.identifier,
godfather_id=invite.godfather_id,
godfather_email=godfather_email,
status=invite.status.value,
used_by_id=invite.used_by_id,
used_by_email=None,
created_at=invite.created_at,
spent_at=invite.spent_at,
revoked_at=invite.revoked_at,
)

View file

@ -23,6 +23,17 @@ class Permission(str, PyEnum):
# Audit permissions
VIEW_AUDIT = "view_audit"
# Invite permissions
MANAGE_INVITES = "manage_invites"
VIEW_OWN_INVITES = "view_own_invites"
class InviteStatus(str, PyEnum):
"""Status of an invite."""
READY = "ready"
SPENT = "spent"
REVOKED = "revoked"
# Role name constants
@ -32,17 +43,19 @@ ROLE_REGULAR = "regular"
# Role definitions with their permissions
ROLE_DEFINITIONS: dict[str, RoleConfig] = {
ROLE_ADMIN: {
"description": "Administrator with audit access",
"description": "Administrator with audit and invite management access",
"permissions": [
Permission.VIEW_AUDIT,
Permission.MANAGE_INVITES,
],
},
ROLE_REGULAR: {
"description": "Regular user with counter and sum access",
"description": "Regular user with counter, sum, and invite access",
"permissions": [
Permission.VIEW_COUNTER,
Permission.INCREMENT_COUNTER,
Permission.USE_SUM,
Permission.VIEW_OWN_INVITES,
],
},
}
@ -107,6 +120,16 @@ class User(Base):
signal: Mapped[str | None] = mapped_column(String(64), nullable=True)
nostr_npub: Mapped[str | None] = mapped_column(String(63), nullable=True)
# Godfather (who invited this user) - null for seeded/admin users
godfather_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
godfather: Mapped["User | None"] = relationship(
"User",
remote_side="User.id",
foreign_keys=[godfather_id],
)
# Relationship to roles
roles: Mapped[list[Role]] = relationship(
"Role",
@ -165,3 +188,40 @@ class CounterRecord(Base):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
class Invite(Base):
__tablename__ = "invites"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
identifier: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
status: Mapped[InviteStatus] = mapped_column(
Enum(InviteStatus), nullable=False, default=InviteStatus.READY
)
# Godfather - the user who owns this invite
godfather_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False, index=True
)
godfather: Mapped[User] = relationship(
"User",
foreign_keys=[godfather_id],
lazy="selectin",
)
# User who used this invite (null until spent)
used_by_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
used_by: Mapped[User | None] = relationship(
"User",
foreign_keys=[used_by_id],
lazy="selectin",
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
spent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View file

@ -1,7 +1,51 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models import User, Invite, InviteStatus, ROLE_ADMIN
from invite_utils import generate_invite_identifier
def unique_email(prefix: str = "test") -> str:
"""Generate a unique email for tests sharing the same database."""
return f"{prefix}-{uuid.uuid4().hex[:8]}@example.com"
async def create_invite_for_registration(db: AsyncSession, godfather_email: str) -> str:
"""
Create an invite that can be used for registration.
Returns the invite identifier.
"""
# Find godfather
result = await db.execute(select(User).where(User.email == godfather_email))
godfather = result.scalar_one_or_none()
if not godfather:
# Create a godfather user (admin can create invites)
from auth import get_password_hash
from models import Role
result = await db.execute(select(Role).where(Role.name == ROLE_ADMIN))
admin_role = result.scalar_one_or_none()
godfather = User(
email=godfather_email,
hashed_password=get_password_hash("password123"),
roles=[admin_role] if admin_role else [],
)
db.add(godfather)
await db.flush()
# Create invite
identifier = generate_invite_identifier()
invite = Invite(
identifier=identifier,
godfather_id=godfather.id,
status=InviteStatus.READY,
)
db.add(invite)
await db.commit()
return identifier

View file

@ -1,16 +1,31 @@
"""Tests for authentication endpoints.
Note: Registration now requires an invite code. Tests that need to register
users will create invites first via the helper function.
"""
import pytest
from auth import COOKIE_NAME
from tests.helpers import unique_email
from tests.helpers import unique_email, create_invite_for_registration
# Registration tests
# Registration tests (with invite)
@pytest.mark.asyncio
async def test_register_success(client):
async def test_register_success(client_factory):
"""Can register with valid invite code."""
email = unique_email("register")
response = await client.post(
# Create invite
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("godfather"))
response = await client_factory.post(
"/api/auth/register",
json={"email": email, "password": "password123"},
json={
"email": email,
"password": "password123",
"invite_identifier": invite_code,
},
)
assert response.status_code == 200
data = response.json()
@ -25,62 +40,110 @@ async def test_register_success(client):
@pytest.mark.asyncio
async def test_register_duplicate_email(client):
async def test_register_duplicate_email(client_factory):
"""Cannot register with already-used email."""
email = unique_email("duplicate")
await client.post(
# Create two invites
async with client_factory.get_db_session() as db:
invite1 = await create_invite_for_registration(db, unique_email("gf1"))
invite2 = await create_invite_for_registration(db, unique_email("gf2"))
# First registration
await client_factory.post(
"/api/auth/register",
json={"email": email, "password": "password123"},
json={
"email": email,
"password": "password123",
"invite_identifier": invite1,
},
)
response = await client.post(
# Second registration with same email
response = await client_factory.post(
"/api/auth/register",
json={"email": email, "password": "differentpass"},
json={
"email": email,
"password": "differentpass",
"invite_identifier": invite2,
},
)
assert response.status_code == 400
assert response.json()["detail"] == "Email already registered"
@pytest.mark.asyncio
async def test_register_invalid_email(client):
response = await client.post(
async def test_register_invalid_email(client_factory):
"""Cannot register with invalid email format."""
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
response = await client_factory.post(
"/api/auth/register",
json={"email": "notanemail", "password": "password123"},
json={
"email": "notanemail",
"password": "password123",
"invite_identifier": invite_code,
},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_register_missing_password(client):
"""Cannot register without password."""
response = await client.post(
"/api/auth/register",
json={"email": unique_email()},
json={"email": unique_email(), "invite_identifier": "some-code-00"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_register_missing_email(client):
"""Cannot register without email."""
response = await client.post(
"/api/auth/register",
json={"password": "password123"},
json={"password": "password123", "invite_identifier": "some-code-00"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_register_missing_invite(client):
"""Cannot register without invite code."""
response = await client.post(
"/api/auth/register",
json={"email": unique_email(), "password": "password123"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_register_empty_body(client):
"""Cannot register with empty body."""
response = await client.post("/api/auth/register", json={})
assert response.status_code == 422
# Login tests
@pytest.mark.asyncio
async def test_login_success(client):
async def test_login_success(client_factory):
"""Can login with valid credentials."""
email = unique_email("login")
await client.post(
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
await client_factory.post(
"/api/auth/register",
json={"email": email, "password": "password123"},
json={
"email": email,
"password": "password123",
"invite_identifier": invite_code,
},
)
response = await client.post(
response = await client_factory.post(
"/api/auth/login",
json={"email": email, "password": "password123"},
)
@ -93,13 +156,22 @@ async def test_login_success(client):
@pytest.mark.asyncio
async def test_login_wrong_password(client):
async def test_login_wrong_password(client_factory):
"""Cannot login with wrong password."""
email = unique_email("wrongpass")
await client.post(
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
await client_factory.post(
"/api/auth/register",
json={"email": email, "password": "correctpassword"},
json={
"email": email,
"password": "correctpassword",
"invite_identifier": invite_code,
},
)
response = await client.post(
response = await client_factory.post(
"/api/auth/login",
json={"email": email, "password": "wrongpassword"},
)
@ -109,6 +181,7 @@ async def test_login_wrong_password(client):
@pytest.mark.asyncio
async def test_login_nonexistent_user(client):
"""Cannot login with non-existent user."""
response = await client.post(
"/api/auth/login",
json={"email": unique_email("nonexistent"), "password": "password123"},
@ -119,6 +192,7 @@ async def test_login_nonexistent_user(client):
@pytest.mark.asyncio
async def test_login_invalid_email_format(client):
"""Cannot login with invalid email format."""
response = await client.post(
"/api/auth/login",
json={"email": "invalidemail", "password": "password123"},
@ -128,6 +202,7 @@ async def test_login_invalid_email_format(client):
@pytest.mark.asyncio
async def test_login_missing_fields(client):
"""Cannot login with missing fields."""
response = await client.post("/api/auth/login", json={})
assert response.status_code == 422
@ -135,16 +210,22 @@ async def test_login_missing_fields(client):
# Get current user tests
@pytest.mark.asyncio
async def test_get_me_success(client_factory):
"""Can get current user info when authenticated."""
email = unique_email("me")
# Register and get cookies
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
reg_response = await client_factory.post(
"/api/auth/register",
json={"email": email, "password": "password123"},
json={
"email": email,
"password": "password123",
"invite_identifier": invite_code,
},
)
cookies = dict(reg_response.cookies)
# Use authenticated client
async with client_factory.create(cookies=cookies) as authed:
response = await authed.get("/api/auth/me")
@ -158,12 +239,14 @@ async def test_get_me_success(client_factory):
@pytest.mark.asyncio
async def test_get_me_no_cookie(client):
"""Cannot get current user without auth cookie."""
response = await client.get("/api/auth/me")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_get_me_invalid_cookie(client_factory):
"""Cannot get current user with invalid cookie."""
async with client_factory.create(cookies={COOKIE_NAME: "invalidtoken123"}) as authed:
response = await authed.get("/api/auth/me")
assert response.status_code == 401
@ -172,6 +255,7 @@ async def test_get_me_invalid_cookie(client_factory):
@pytest.mark.asyncio
async def test_get_me_expired_token(client_factory):
"""Cannot get current user with expired token."""
bad_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImV4cCI6MH0.invalid"
async with client_factory.create(cookies={COOKIE_NAME: bad_token}) as authed:
response = await authed.get("/api/auth/me")
@ -181,11 +265,19 @@ async def test_get_me_expired_token(client_factory):
# Cookie validation tests
@pytest.mark.asyncio
async def test_cookie_from_register_works_for_me(client_factory):
"""Auth cookie from registration works for subsequent requests."""
email = unique_email("tokentest")
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
reg_response = await client_factory.post(
"/api/auth/register",
json={"email": email, "password": "password123"},
json={
"email": email,
"password": "password123",
"invite_identifier": invite_code,
},
)
cookies = dict(reg_response.cookies)
@ -198,11 +290,19 @@ async def test_cookie_from_register_works_for_me(client_factory):
@pytest.mark.asyncio
async def test_cookie_from_login_works_for_me(client_factory):
"""Auth cookie from login works for subsequent requests."""
email = unique_email("logintoken")
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
await client_factory.post(
"/api/auth/register",
json={"email": email, "password": "password123"},
json={
"email": email,
"password": "password123",
"invite_identifier": invite_code,
},
)
login_response = await client_factory.post(
"/api/auth/login",
@ -220,16 +320,29 @@ async def test_cookie_from_login_works_for_me(client_factory):
# Multiple users tests
@pytest.mark.asyncio
async def test_multiple_users_isolated(client_factory):
"""Multiple users have isolated sessions."""
email1 = unique_email("user1")
email2 = unique_email("user2")
async with client_factory.get_db_session() as db:
invite1 = await create_invite_for_registration(db, unique_email("gf1"))
invite2 = await create_invite_for_registration(db, unique_email("gf2"))
resp1 = await client_factory.post(
"/api/auth/register",
json={"email": email1, "password": "password1"},
json={
"email": email1,
"password": "password1",
"invite_identifier": invite1,
},
)
resp2 = await client_factory.post(
"/api/auth/register",
json={"email": email2, "password": "password2"},
json={
"email": email2,
"password": "password2",
"invite_identifier": invite2,
},
)
cookies1 = dict(resp1.cookies)
@ -248,13 +361,22 @@ async def test_multiple_users_isolated(client_factory):
# Password tests
@pytest.mark.asyncio
async def test_password_is_hashed(client):
async def test_password_is_hashed(client_factory):
"""Passwords are properly hashed (can login with correct password)."""
email = unique_email("hashtest")
await client.post(
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
await client_factory.post(
"/api/auth/register",
json={"email": email, "password": "mySecurePassword123"},
json={
"email": email,
"password": "mySecurePassword123",
"invite_identifier": invite_code,
},
)
response = await client.post(
response = await client_factory.post(
"/api/auth/login",
json={"email": email, "password": "mySecurePassword123"},
)
@ -262,13 +384,22 @@ async def test_password_is_hashed(client):
@pytest.mark.asyncio
async def test_case_sensitive_password(client):
async def test_case_sensitive_password(client_factory):
"""Passwords are case-sensitive."""
email = unique_email("casetest")
await client.post(
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
await client_factory.post(
"/api/auth/register",
json={"email": email, "password": "Password123"},
json={
"email": email,
"password": "Password123",
"invite_identifier": invite_code,
},
)
response = await client.post(
response = await client_factory.post(
"/api/auth/login",
json={"email": email, "password": "password123"},
)
@ -278,11 +409,19 @@ async def test_case_sensitive_password(client):
# Logout tests
@pytest.mark.asyncio
async def test_logout_success(client_factory):
"""Can logout successfully."""
email = unique_email("logout")
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
reg_response = await client_factory.post(
"/api/auth/register",
json={"email": email, "password": "password123"},
json={
"email": email,
"password": "password123",
"invite_identifier": invite_code,
},
)
cookies = dict(reg_response.cookies)

View file

@ -1,7 +1,11 @@
"""Tests for counter endpoints.
Note: Registration now requires an invite code.
"""
import pytest
from auth import COOKIE_NAME
from tests.helpers import unique_email
from tests.helpers import unique_email, create_invite_for_registration
# Protected endpoint tests - without auth
@ -34,9 +38,16 @@ async def test_increment_counter_invalid_cookie(client_factory):
# Authenticated counter tests
@pytest.mark.asyncio
async def test_get_counter_authenticated(client_factory):
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
reg = await client_factory.post(
"/api/auth/register",
json={"email": unique_email(), "password": "testpass123"},
json={
"email": unique_email(),
"password": "testpass123",
"invite_identifier": invite_code,
},
)
cookies = dict(reg.cookies)
@ -49,9 +60,16 @@ async def test_get_counter_authenticated(client_factory):
@pytest.mark.asyncio
async def test_increment_counter(client_factory):
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
reg = await client_factory.post(
"/api/auth/register",
json={"email": unique_email(), "password": "testpass123"},
json={
"email": unique_email(),
"password": "testpass123",
"invite_identifier": invite_code,
},
)
cookies = dict(reg.cookies)
@ -68,9 +86,16 @@ async def test_increment_counter(client_factory):
@pytest.mark.asyncio
async def test_increment_counter_multiple(client_factory):
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
reg = await client_factory.post(
"/api/auth/register",
json={"email": unique_email(), "password": "testpass123"},
json={
"email": unique_email(),
"password": "testpass123",
"invite_identifier": invite_code,
},
)
cookies = dict(reg.cookies)
@ -89,9 +114,16 @@ async def test_increment_counter_multiple(client_factory):
@pytest.mark.asyncio
async def test_get_counter_after_increment(client_factory):
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
reg = await client_factory.post(
"/api/auth/register",
json={"email": unique_email(), "password": "testpass123"},
json={
"email": unique_email(),
"password": "testpass123",
"invite_identifier": invite_code,
},
)
cookies = dict(reg.cookies)
@ -109,10 +141,19 @@ async def test_get_counter_after_increment(client_factory):
# Counter is shared between users
@pytest.mark.asyncio
async def test_counter_shared_between_users(client_factory):
# Create invites for two users
async with client_factory.get_db_session() as db:
invite1 = await create_invite_for_registration(db, unique_email("gf1"))
invite2 = await create_invite_for_registration(db, unique_email("gf2"))
# Create first user
reg1 = await client_factory.post(
"/api/auth/register",
json={"email": unique_email("share1"), "password": "testpass123"},
json={
"email": unique_email("share1"),
"password": "testpass123",
"invite_identifier": invite1,
},
)
cookies1 = dict(reg1.cookies)
@ -127,7 +168,11 @@ async def test_counter_shared_between_users(client_factory):
# Create second user - should see the increments
reg2 = await client_factory.post(
"/api/auth/register",
json={"email": unique_email("share2"), "password": "testpass123"},
json={
"email": unique_email("share2"),
"password": "testpass123",
"invite_identifier": invite2,
},
)
cookies2 = dict(reg2.cookies)

File diff suppressed because it is too large Load diff

View file

@ -305,11 +305,18 @@ class TestSecurityBypassAttempts:
Test that new registrations cannot claim admin role.
New users should only get 'regular' role by default.
"""
from tests.helpers import unique_email
from tests.helpers import unique_email, create_invite_for_registration
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
response = await client_factory.post(
"/api/auth/register",
json={"email": unique_email(), "password": "password123"},
json={
"email": unique_email(),
"password": "password123",
"invite_identifier": invite_code,
},
)
assert response.status_code == 200

View file

@ -397,3 +397,57 @@ class TestProfilePrivacy:
assert "telegram" not in data
assert "signal" not in data
assert "nostr_npub" not in data
class TestProfileGodfather:
"""Tests for godfather information in profile."""
async def test_profile_shows_godfather_email(self, client_factory, admin_user, regular_user):
"""Profile shows godfather email for users who signed up with invite."""
from tests.helpers import unique_email
from sqlalchemy import select
from models import User
# Create invite
async with client_factory.create(cookies=admin_user["cookies"]) as client:
async with client_factory.get_db_session() as db:
result = await db.execute(
select(User).where(User.email == regular_user["email"])
)
godfather = result.scalar_one()
create_resp = await client.post(
"/api/admin/invites",
json={"godfather_id": godfather.id},
)
identifier = create_resp.json()["identifier"]
# Register new user with invite
new_email = unique_email("godchild")
async with client_factory.create() as client:
reg_resp = await client.post(
"/api/auth/register",
json={
"email": new_email,
"password": "password123",
"invite_identifier": identifier,
},
)
new_user_cookies = dict(reg_resp.cookies)
# Check profile shows godfather
async with client_factory.create(cookies=new_user_cookies) as client:
response = await client.get("/api/profile")
assert response.status_code == 200
data = response.json()
assert data["godfather_email"] == regular_user["email"]
async def test_profile_godfather_null_for_seeded_users(self, client_factory, regular_user):
"""Profile shows null godfather for users without one (e.g., seeded users)."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/profile")
assert response.status_code == 200
data = response.json()
assert data["godfather_email"] is None

2048
backend/words.txt Normal file

File diff suppressed because it is too large Load diff