Minimal v2 endpoints scaffolded alongside v1 cookie auth
This commit is contained in:
parent
aa74972fdf
commit
f1dac94396
15 changed files with 672 additions and 23 deletions
62
api/auth_v2.py
Normal file
62
api/auth_v2.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from api.deps import error_response, get_db
|
||||
from common import ApiModel
|
||||
from users import repository as users_db
|
||||
from users.models import User
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth-v2"])
|
||||
|
||||
|
||||
class RegisterBody(ApiModel):
|
||||
email: str
|
||||
password: str
|
||||
display_name: str
|
||||
|
||||
|
||||
class LoginBody(ApiModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(ApiModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: User
|
||||
|
||||
|
||||
def _hash_pw(pw: str) -> str:
|
||||
# Placeholder; replace with proper hashing (bcrypt/argon2) later
|
||||
return hashlib.sha256(pw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, operation_id="register")
|
||||
async def register(request: Request, body: RegisterBody, conn: aiosqlite.Connection = Depends(get_db)):
|
||||
existing = await users_db.get_by_email(conn, body.email)
|
||||
if existing:
|
||||
return error_response(request, 400, "Email already registered")
|
||||
uid = await users_db.insert_user(conn, body.email, body.display_name)
|
||||
await users_db.set_local_credentials(conn, uid, _hash_pw(body.password))
|
||||
user = await users_db.get_by_email(conn, body.email)
|
||||
assert user is not None
|
||||
# Token is a simple placeholder containing user id; will be replaced with JWT
|
||||
token = f"user-{user.id}"
|
||||
return TokenResponse(access_token=token, user=user)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse, operation_id="loginV2")
|
||||
async def login(request: Request, body: LoginBody, conn: aiosqlite.Connection = Depends(get_db)):
|
||||
user: Optional[User] = await users_db.get_by_email(conn, body.email)
|
||||
if not user:
|
||||
return error_response(request, 401, "Invalid credentials")
|
||||
stored = await users_db.get_local_password_hash(conn, user.id)
|
||||
if not stored or stored != _hash_pw(body.password):
|
||||
return error_response(request, 401, "Invalid credentials")
|
||||
token = f"user-{user.id}"
|
||||
return TokenResponse(access_token=token, user=user)
|
||||
28
api/deps.py
28
api/deps.py
|
|
@ -10,6 +10,8 @@ import db
|
|||
import persons
|
||||
from common import ProblemDetails
|
||||
from settings import settings
|
||||
from users import repository as users_db
|
||||
from users.models import User
|
||||
|
||||
|
||||
# Dependency to create SQLite connection with PRAGMAs and per-request transaction
|
||||
|
|
@ -77,3 +79,29 @@ def error_response(request: Optional[Request], status_code: int, message: str) -
|
|||
status_code=status_code,
|
||||
media_type="application/problem+json",
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(request: Request, conn: aiosqlite.Connection = Depends(get_db)) -> User:
|
||||
"""Temporary bearer token auth: expects Authorization: Bearer user-<id>.
|
||||
|
||||
This is a stopgap until JWT is implemented. Returns 401 on failure.
|
||||
"""
|
||||
auth = request.headers.get("Authorization")
|
||||
if not auth or not auth.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
token = auth.split(" ", 1)[1].strip()
|
||||
if not token.startswith("user-"):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
try:
|
||||
user_id = int(token.split("-", 1)[1])
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
# Lookup by id
|
||||
async with conn.execute(
|
||||
"SELECT id, email, display_name, profile_photo_url FROM User WHERE id = ?",
|
||||
(user_id,),
|
||||
) as c:
|
||||
row = await c.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return User(id=int(row[0]), email=row[1], display_name=row[2], profile_photo_url=row[3])
|
||||
|
|
|
|||
72
api/households.py
Normal file
72
api/households.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from api.deps import get_current_user, get_db, error_response
|
||||
from common import ApiModel
|
||||
from users.models import User
|
||||
|
||||
router = APIRouter(tags=["households"])
|
||||
|
||||
|
||||
class CreateHouseholdBody(ApiModel):
|
||||
name: str
|
||||
|
||||
|
||||
class HouseholdResponse(ApiModel):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||
return s or "household"
|
||||
|
||||
|
||||
@router.get("/users/me/households", response_model=List[HouseholdResponse])
|
||||
async def list_my_households(
|
||||
request: Request, user: User = Depends(get_current_user), conn: aiosqlite.Connection = Depends(get_db)
|
||||
):
|
||||
results: list[HouseholdResponse] = []
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT h.id, h.name, h.slug FROM Household h
|
||||
JOIN HouseholdMember m ON m.household_id = h.id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY h.id
|
||||
""",
|
||||
(user.id,),
|
||||
) as c:
|
||||
async for row in c:
|
||||
results.append(HouseholdResponse(id=int(row[0]), name=row[1], slug=row[2]))
|
||||
return results
|
||||
|
||||
|
||||
@router.post("/households", response_model=HouseholdResponse)
|
||||
async def create_household(
|
||||
request: Request,
|
||||
body: CreateHouseholdBody,
|
||||
user: User = Depends(get_current_user),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
slug = slugify(body.name)
|
||||
try:
|
||||
async with conn.execute(
|
||||
"INSERT INTO Household (name, slug) VALUES (?, ?)", (body.name, slug)
|
||||
) as cur:
|
||||
lrid = cur.lastrowid
|
||||
if lrid is None:
|
||||
return error_response(request, 400, "Unable to create household")
|
||||
hid = int(lrid)
|
||||
await conn.execute(
|
||||
"INSERT INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?)",
|
||||
(user.id, hid, "admin"),
|
||||
)
|
||||
return HouseholdResponse(id=hid, name=body.name, slug=slug)
|
||||
except Exception:
|
||||
return error_response(request, 400, "Unable to create household")
|
||||
|
|
@ -173,39 +173,58 @@ Impact on existing routes (exact files to refactor):
|
|||
|
||||
## 4. Actionable Implementation Steps
|
||||
|
||||
1. **[ ] Database Schema and Migration**:
|
||||
- **Schema Definition**: Define the new tables (`users`, `households`, etc.) in new repository files (e.g., `users/repository.py`, `households/repository.py`). The `create` function in each will contain the `CREATE TABLE` SQL.
|
||||
- **Migration Logic**: Create a new migration script (e.g., in `scripts/migration_to_households.py`). This script will:
|
||||
1. **[~] Database Schema and Migration**:
|
||||
- ✅ **Schema Definition**: Added new packages and tables:
|
||||
- `users` with tables `User`, `LocalCredentials`, `OAuthCredentials` (see `users/repository.py`).
|
||||
- `households` with tables `Household`, `HouseholdMember`, `HouseholdInvitation` (see `households/repository.py`).
|
||||
- `db.create()` now initializes these tables alongside existing v1 tables.
|
||||
- ✅ **Migration Logic**: Implemented `scripts/migration_to_households.py` which:
|
||||
- Connect to the database (reusing logic from `db.py`).
|
||||
- Call the `create()` function for each new repository to create the tables.
|
||||
- Add a `household_id` column to all existing tenant-specific tables (`recipes`, `meals`, etc.).
|
||||
- **Data Porting**:
|
||||
- Create a single default "My Household".
|
||||
- Read all records from the `persons` table.
|
||||
- For each person, create a corresponding record in `users` and `household_members`.
|
||||
- Backfill the `household_id` in all existing resources with the ID of the default household.
|
||||
- Calls `create()` for new repos to ensure tables exist.
|
||||
- Adds a `household_id` column to tenant tables: `Recipe`, `Ingredient`, `Meal`, `MealParticipant`, `MealRecipe`, `ShoppingList`, `ShoppingListItem` (idempotent).
|
||||
- Creates indices `idx_<table>_household_id` for all above tables.
|
||||
- Creates a default household `{ name: "My Household", slug: "default" }` and backfills `household_id` with its ID for existing rows.
|
||||
- Ports `Person` rows to `User` (email derived as `<name>@example.com`) and creates `HouseholdMember` links (role `admin`).
|
||||
- **Bootstrap Update**: Modify `db.py` so that a fresh database bootstrap (`db.create_schema`) calls the `create()` functions for the new repositories and *not* the old `persons` repository.
|
||||
- **Indices/Constraints**: Add unique `households.slug`; composite indexes on `(household_id, id)` per table; foreign keys with `ON DELETE CASCADE` where appropriate.
|
||||
- **Acceptance**: Fresh bootstrap creates all tables; migration script idempotently adds columns and backfills; existing tests still pass against default household.
|
||||
- ✅ **Indices/Constraints**: Enforced unique `Household.slug`; added `idx_*_household_id` indices; foreign keys added with `ON DELETE CASCADE` where applicable in new tables.
|
||||
- ✅ **Acceptance (initial)**: Added `tests/test_migration_households.py` covering: new tables exist, `household_id` columns exist, default household created, and data porting from `Person` to `User` and `HouseholdMember`. Full test suite passes.
|
||||
|
||||
2. **[ ] Implement New Authentication System**:
|
||||
- **Refactor `api/auth.py`**: Gut the existing cookie-based logic. Implement the new `/register`, `/login`, and `/google` endpoints.
|
||||
- **Create `users/` package**: Add `models.py` and `repository.py` for the new `User` entity.
|
||||
- **Update `api/deps.py`**: Replace `get_current_person` with a new `get_current_user` dependency that validates the JWT and returns the `User` model.
|
||||
- Pending follow-ups for this step:
|
||||
- Add composite indices `(household_id, id)` where high-cardinality pagination will benefit.
|
||||
- Extend migration to add FK constraints from tenant tables to `Household(id)` where safe.
|
||||
- Plan and implement data backfill for cross-table references once `users` replace `persons` in code.
|
||||
|
||||
2. **[~] Implement New Authentication System**:
|
||||
- ✅ Minimal v2 endpoints scaffolded alongside v1 cookie auth (no breakage):
|
||||
- Added `api/auth_v2.py` with `/api/v1/auth/register` and `/api/v1/auth/login`. Currently returns a simple bearer token of the form `user-<id>`.
|
||||
- Added `users/repository.py` helpers: `get_by_email`, `insert_user`, `set_local_credentials`, `get_local_password_hash`.
|
||||
- Added `get_current_user` dependency in `api/deps.py` that reads `Authorization: Bearer user-<id>` and returns the `User`.
|
||||
- Wired v2 router in `main.py` without removing v1 cookie routes.
|
||||
- Added tests: `tests/test_auth_and_households_v2.py` registers/logs in a user and exercises bearer auth.
|
||||
- Pending (to complete this step):
|
||||
- Replace placeholder token with real JWT signing/verification and introduce refresh tokens via HttpOnly cookie.
|
||||
- Update `api/openapi.py` security scheme from cookie to bearer JWT and mark protected operations.
|
||||
- Remove `persons` dependency from protected endpoints once household scoping is in place.
|
||||
- **Token plumbing**: Configure signing keys, token lifetimes, and `HttpOnly` refresh cookie. Consider `Authorization: Bearer` for access tokens.
|
||||
- **OpenAPI**: Update `api/openapi.py` to replace `cookieAuth` with `bearerAuth` (JWT) and mark protected operations accordingly.
|
||||
- **Acceptance**: Protected endpoints reject unauthenticated with 401; membership failures yield 403; tests updated to generate JWTs.
|
||||
|
||||
3. **[ ] Implement Household Scoping**:
|
||||
- **Create `households/` package**: Add `models.py` and `repository.py` for `Household`, `HouseholdMember`, and `HouseholdInvitation`.
|
||||
- **Implement `get_household_from_slug`** in `api/deps.py`.
|
||||
- **Refactor `main.py`**:
|
||||
3. **[~] Implement Household Scoping**:
|
||||
- ✅ Created `households/` package with `models.py` and `repository.py`.
|
||||
- ✅ Added initial `api/households.py` router:
|
||||
- `GET /api/v1/users/me/households` (requires bearer token) → lists memberships.
|
||||
- `POST /api/v1/households` (requires bearer token) → creates household and adds current user as admin.
|
||||
- ⏳ Implement `get_household_from_slug` in `api/deps.py`.
|
||||
- ⏳ Refactor `main.py`:
|
||||
- Create a new `APIRouter` for household-scoped routes, e.g., `household_router = APIRouter(prefix="/api/v1/households/{householdSlug}")`.
|
||||
- Mount the existing routers (`recipes_api`, `meals_api`, etc.) onto this new `household_router` instead of the main `app`.
|
||||
- **Update Repositories**: Modify all repository functions (e.g., `recipes.repository.get_all`, `meals.repository.create`) to accept a `household_id` and use it in the `WHERE` clause of every SQL query.
|
||||
- **Update Routers**: Add the `get_household_from_slug` dependency to all household-scoped routes and pass the resulting `household_id` to the repository functions.
|
||||
- Mount the existing routers (`recipes_api`, `meals_api`, etc.) onto this `household_router`.
|
||||
- ⏳ Update Repositories: modify all repository functions to accept `household_id` and filter by it.
|
||||
- ⏳ Update Routers: add `get_household_from_slug` dependency to all scoped routes and plumb `household_id`.
|
||||
- **Acceptance**: The same queries as v1, when run under different household slugs and users, return isolated data sets; cross-household access yields 403.
|
||||
|
||||
- Notes:
|
||||
- Migration added `household_id` columns and indices, enabling next step to filter by household without additional schema changes.
|
||||
|
||||
4. **[ ] Implement Household & Invitation Logic**:
|
||||
- Create the `api/households.py` router and implement the endpoints for creating households, listing the user's households, and managing invitations.
|
||||
- Add the logic for sending invitation emails (this may require a new utility/service for sending emails).
|
||||
|
|
|
|||
14
db.py
14
db.py
|
|
@ -20,6 +20,20 @@ async def create(conn: aiosqlite.Connection):
|
|||
|
||||
await recipe_db.create(conn)
|
||||
|
||||
# New v2 domain tables (users/households). Keep persons for compatibility during migration.
|
||||
try:
|
||||
import users.repository as users_db
|
||||
await users_db.create(conn)
|
||||
except Exception:
|
||||
# Be tolerant if table already exists or module missing in some setups
|
||||
pass
|
||||
|
||||
try:
|
||||
import households.repository as households_db
|
||||
await households_db.create(conn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import persons.repository as person_db
|
||||
|
||||
await person_db.create(conn)
|
||||
|
|
|
|||
2
households/__init__.py
Normal file
2
households/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from households.models import Household as Household, HouseholdInvitation as HouseholdInvitation
|
||||
from households.repository import create as create
|
||||
39
households/models.py
Normal file
39
households/models.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from typing import ClassVar, Optional
|
||||
|
||||
from common import ApiModel
|
||||
|
||||
|
||||
class Household(ApiModel):
|
||||
KEYS: ClassVar[list[str]] = ["id", "name", "slug"]
|
||||
|
||||
id: int = -1
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
|
||||
class HouseholdMember(ApiModel):
|
||||
KEYS: ClassVar[list[str]] = ["user_id", "household_id", "role"]
|
||||
|
||||
user_id: int
|
||||
household_id: int
|
||||
role: str
|
||||
|
||||
|
||||
class HouseholdInvitation(ApiModel):
|
||||
KEYS: ClassVar[list[str]] = [
|
||||
"id",
|
||||
"household_id",
|
||||
"email",
|
||||
"invited_by_user_id",
|
||||
"token",
|
||||
"expires_at",
|
||||
"status",
|
||||
]
|
||||
|
||||
id: int = -1
|
||||
household_id: int
|
||||
email: str
|
||||
invited_by_user_id: int
|
||||
token: str
|
||||
expires_at: str
|
||||
status: str
|
||||
50
households/repository.py
Normal file
50
households/repository.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
async def create(conn):
|
||||
# Households table
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Household (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# Membership table
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS HouseholdMember (
|
||||
user_id INTEGER NOT NULL,
|
||||
household_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, household_id),
|
||||
FOREIGN KEY(user_id) REFERENCES User(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(household_id) REFERENCES Household(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# Invitations table
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS HouseholdInvitation (
|
||||
id INTEGER PRIMARY KEY,
|
||||
household_id INTEGER NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
invited_by_user_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
FOREIGN KEY(household_id) REFERENCES Household(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(invited_by_user_id) REFERENCES User(id) ON DELETE SET NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# Indices
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_household_slug ON Household(slug);"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_household_member_household ON HouseholdMember(household_id);"
|
||||
)
|
||||
5
main.py
5
main.py
|
|
@ -10,11 +10,13 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
|||
|
||||
from api import (
|
||||
auth as auth_router,
|
||||
auth_v2 as auth_v2_router,
|
||||
meals as meals_router,
|
||||
persons as persons_router,
|
||||
products as products_router,
|
||||
recipes as recipes_router,
|
||||
shopping as shopping_router,
|
||||
households as households_router,
|
||||
)
|
||||
from api.deps import (
|
||||
cookie_person as cookie_person, # noqa: F401 - re-exported for tests
|
||||
|
|
@ -159,6 +161,9 @@ def create_app() -> FastAPI:
|
|||
app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||
app.include_router(persons_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||
app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||
# Experimental v2 auth endpoints (JWT to be implemented). Kept alongside v1 during transition.
|
||||
app.include_router(auth_v2_router.router, prefix="/api/v1", tags=["v2"])
|
||||
app.include_router(households_router.router, prefix="/api/v1", tags=["v2"]) # new
|
||||
|
||||
# Routes
|
||||
app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus)
|
||||
|
|
|
|||
136
scripts/migration_to_households.py
Normal file
136
scripts/migration_to_households.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
import db
|
||||
from settings import settings
|
||||
|
||||
|
||||
async def column_exists(conn: aiosqlite.Connection, table: str, column: str) -> bool:
|
||||
async with conn.execute(f"PRAGMA table_info({table});") as cursor:
|
||||
async for row in cursor:
|
||||
if row[1] == column:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def add_column_if_missing(conn: aiosqlite.Connection, table: str, column_def: str) -> None:
|
||||
# column_def like "household_id INTEGER"
|
||||
col_name = column_def.split()[0]
|
||||
if not await column_exists(conn, table, col_name):
|
||||
await conn.execute(f"ALTER TABLE {table} ADD COLUMN {column_def};")
|
||||
|
||||
|
||||
async def ensure_default_household(conn: aiosqlite.Connection) -> int:
|
||||
# Create a default household and return its id, idempotently
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO Household (name, slug)
|
||||
VALUES ('My Household', 'default')
|
||||
ON CONFLICT(slug) DO NOTHING
|
||||
"""
|
||||
)
|
||||
async with conn.execute("SELECT id FROM Household WHERE slug = 'default' LIMIT 1;") as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
return int(row[0])
|
||||
|
||||
|
||||
async def backfill_table_household_id(
|
||||
conn: aiosqlite.Connection, table: str, default_household_id: int
|
||||
) -> None:
|
||||
# If any NULL household_id rows exist, backfill to default
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET household_id = ?
|
||||
WHERE household_id IS NULL
|
||||
""",
|
||||
(default_household_id,),
|
||||
)
|
||||
|
||||
|
||||
async def run_migration(conn: Optional[aiosqlite.Connection] = None):
|
||||
owned = False
|
||||
if conn is None:
|
||||
conn = await db.connect(settings.database_path)
|
||||
owned = True
|
||||
try:
|
||||
# Ensure new domain tables exist
|
||||
from users import repository as users_db
|
||||
from households import repository as households_db
|
||||
|
||||
await users_db.create(conn)
|
||||
await households_db.create(conn)
|
||||
|
||||
# Add household_id columns to tenant tables
|
||||
for table in [
|
||||
"Recipe",
|
||||
"Ingredient",
|
||||
"Meal",
|
||||
"MealParticipant",
|
||||
"MealRecipe",
|
||||
"ShoppingList",
|
||||
"ShoppingListItem",
|
||||
]:
|
||||
await add_column_if_missing(conn, table, "household_id INTEGER")
|
||||
|
||||
# Backfill default household
|
||||
default_hid = await ensure_default_household(conn)
|
||||
for table in [
|
||||
"Recipe",
|
||||
"Ingredient",
|
||||
"Meal",
|
||||
"MealParticipant",
|
||||
"MealRecipe",
|
||||
"ShoppingList",
|
||||
"ShoppingListItem",
|
||||
]:
|
||||
await backfill_table_household_id(conn, table, default_hid)
|
||||
|
||||
# Create indices on household_id for efficient scoping
|
||||
for table in [
|
||||
"Recipe",
|
||||
"Ingredient",
|
||||
"Meal",
|
||||
"MealParticipant",
|
||||
"MealRecipe",
|
||||
"ShoppingList",
|
||||
"ShoppingListItem",
|
||||
]:
|
||||
await conn.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS idx_{table.lower()}_household_id ON {table}(household_id);"
|
||||
)
|
||||
|
||||
# Port persons -> users and create memberships in default household
|
||||
# Only perform if users table currently empty
|
||||
async with conn.execute("SELECT COUNT(1) FROM User;") as c:
|
||||
row = await c.fetchone()
|
||||
user_count = int(row[0]) if row else 0
|
||||
if user_count == 0:
|
||||
async with conn.execute("SELECT id, name FROM Person;") as cur:
|
||||
async for pid, name in cur:
|
||||
email = f"{name.lower()}@example.com"
|
||||
display_name = name
|
||||
# Insert user
|
||||
await conn.execute(
|
||||
"INSERT INTO User (id, email, display_name) VALUES (?, ?, ?)\n ON CONFLICT(id) DO NOTHING;",
|
||||
(pid, email, display_name),
|
||||
)
|
||||
# Create membership
|
||||
await conn.execute(
|
||||
"INSERT OR IGNORE INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?);",
|
||||
(pid, default_hid, "admin"),
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
finally:
|
||||
if owned:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_migration())
|
||||
47
tests/test_auth_and_households_v2.py
Normal file
47
tests/test_auth_and_households_v2.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import unittest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from db import connect, create
|
||||
|
||||
|
||||
class TestAuthAndHouseholdsV2(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
|
||||
async def override_get_db():
|
||||
try:
|
||||
yield self.conn
|
||||
finally:
|
||||
pass
|
||||
|
||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||
self.client = TestClient(main.app)
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.conn.close()
|
||||
main.app.dependency_overrides.clear()
|
||||
|
||||
def test_register_and_login_and_households(self):
|
||||
# Register a user
|
||||
r = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "test@example.com", "password": "pw", "displayName": "Test"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
token = body["accessToken"]
|
||||
assert token.startswith("user-")
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
# List households (migration created default household 'default' and membership set to admin)
|
||||
r = self.client.get("/api/v1/users/me/households", headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
households = r.json()
|
||||
|
||||
# Create a new household
|
||||
r = self.client.post("/api/v1/households", headers=headers, json={"name": "Family"})
|
||||
assert r.status_code == 200, r.text
|
||||
created = r.json()
|
||||
assert created["slug"].startswith("family")
|
||||
77
tests/test_migration_households.py
Normal file
77
tests/test_migration_households.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import asyncio
|
||||
|
||||
import aiosqlite
|
||||
|
||||
import db
|
||||
from tests.test_data import create_test_data
|
||||
from scripts.migration_to_households import run_migration
|
||||
|
||||
|
||||
async def table_has_column(conn: aiosqlite.Connection, table: str, col: str) -> bool:
|
||||
async with conn.execute(f"PRAGMA table_info({table});") as c:
|
||||
async for row in c:
|
||||
if row[1] == col:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def test_migration_adds_tables_and_columns_and_ports_data(tmp_path):
|
||||
async def _run():
|
||||
db_path = tmp_path / "test.sqlite"
|
||||
conn = await db.connect(str(db_path))
|
||||
# Bootstrap v1 schema
|
||||
await db.create(conn)
|
||||
await conn.commit()
|
||||
|
||||
# Seed some v1 data (persons, products, recipes, meals)
|
||||
await create_test_data(conn)
|
||||
await conn.commit()
|
||||
|
||||
# Run migration
|
||||
await run_migration(conn)
|
||||
|
||||
# Verify new tables exist
|
||||
for tbl in ["User", "LocalCredentials", "OAuthCredentials", "Household", "HouseholdMember", "HouseholdInvitation"]:
|
||||
async with conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?;", (tbl,)
|
||||
) as c:
|
||||
assert await c.fetchone() is not None, f"Missing table {tbl}"
|
||||
|
||||
# Verify household_id column exists on tenant tables
|
||||
tenant_tables = [
|
||||
"Recipe",
|
||||
"Ingredient",
|
||||
"Meal",
|
||||
"MealParticipant",
|
||||
"MealRecipe",
|
||||
"ShoppingList",
|
||||
"ShoppingListItem",
|
||||
]
|
||||
for tbl in tenant_tables:
|
||||
assert await table_has_column(conn, tbl, "household_id"), f"{tbl} lacks household_id"
|
||||
|
||||
# Default household exists
|
||||
async with conn.execute("SELECT id, slug FROM Household WHERE slug='default' LIMIT 1;") as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
|
||||
# Persons were ported to Users and memberships created
|
||||
async with conn.execute("SELECT COUNT(1) FROM Person;") as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
person_count = int(row[0])
|
||||
async with conn.execute("SELECT COUNT(1) FROM User;") as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
user_count = int(row[0])
|
||||
assert user_count == person_count
|
||||
|
||||
async with conn.execute("SELECT COUNT(1) FROM HouseholdMember;") as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
member_count = int(row[0])
|
||||
assert member_count == person_count
|
||||
|
||||
await conn.close()
|
||||
|
||||
asyncio.run(_run())
|
||||
2
users/__init__.py
Normal file
2
users/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from users.models import User as User
|
||||
from users.repository import create as create
|
||||
17
users/models.py
Normal file
17
users/models.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from typing import ClassVar, Optional
|
||||
|
||||
from common import ApiModel
|
||||
|
||||
|
||||
class User(ApiModel):
|
||||
KEYS: ClassVar[list[str]] = [
|
||||
"id",
|
||||
"email",
|
||||
"display_name",
|
||||
"profile_photo_url",
|
||||
]
|
||||
|
||||
id: int = -1
|
||||
email: str
|
||||
display_name: str
|
||||
profile_photo_url: Optional[str] = None
|
||||
79
users/repository.py
Normal file
79
users/repository.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
async def create(conn):
|
||||
# Users core table
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS User (
|
||||
id INTEGER PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
profile_photo_url TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# Local credential storage for password auth
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS LocalCredentials (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
hashed_password TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES User(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# OAuth provider links (e.g., Google)
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS OAuthCredentials (
|
||||
user_id INTEGER NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
provider_user_id TEXT NOT NULL,
|
||||
PRIMARY KEY (provider, provider_user_id),
|
||||
FOREIGN KEY(user_id) REFERENCES User(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# Helpful indices
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_email ON User(email);"
|
||||
)
|
||||
|
||||
|
||||
async def get_by_email(conn, email: str):
|
||||
async with conn.execute(
|
||||
"SELECT id, email, display_name, profile_photo_url FROM User WHERE email = ? LIMIT 1",
|
||||
(email,),
|
||||
) as c:
|
||||
row = await c.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
from users.models import User
|
||||
|
||||
return User(id=int(row[0]), email=row[1], display_name=row[2], profile_photo_url=row[3])
|
||||
|
||||
|
||||
async def insert_user(conn, email: str, display_name: str, profile_photo_url: str | None = None):
|
||||
async with conn.execute(
|
||||
"INSERT INTO User (email, display_name, profile_photo_url) VALUES (?, ?, ?)",
|
||||
(email, display_name, profile_photo_url),
|
||||
) as cur:
|
||||
user_id = cur.lastrowid
|
||||
return user_id
|
||||
|
||||
|
||||
async def set_local_credentials(conn, user_id: int, hashed_password: str):
|
||||
await conn.execute(
|
||||
"INSERT OR REPLACE INTO LocalCredentials (user_id, hashed_password) VALUES (?, ?)",
|
||||
(user_id, hashed_password),
|
||||
)
|
||||
|
||||
|
||||
async def get_local_password_hash(conn, user_id: int) -> str | None:
|
||||
async with conn.execute(
|
||||
"SELECT hashed_password FROM LocalCredentials WHERE user_id = ?",
|
||||
(user_id,),
|
||||
) as c:
|
||||
row = await c.fetchone()
|
||||
return row[0] if row else None
|
||||
Loading…
Reference in a new issue