feat(auth, invitations): JWT access + refresh cookie, invitations API with tests
This commit is contained in:
parent
b5e41dc023
commit
e69299628a
3 changed files with 153 additions and 4 deletions
|
|
@ -84,3 +84,76 @@ class WhoAmI(ApiModel):
|
||||||
@scoped.get("/whoami", response_model=WhoAmI)
|
@scoped.get("/whoami", response_model=WhoAmI)
|
||||||
async def whoami(household=Depends(get_household_from_slug)):
|
async def whoami(household=Depends(get_household_from_slug)):
|
||||||
return WhoAmI(household_id=household["id"], household_slug=household["slug"])
|
return WhoAmI(household_id=household["id"], household_slug=household["slug"])
|
||||||
|
|
||||||
|
|
||||||
|
# Invitations
|
||||||
|
class CreateInvitationBody(ApiModel):
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class InvitationResponse(ApiModel):
|
||||||
|
token: str
|
||||||
|
status: str = "pending"
|
||||||
|
|
||||||
|
|
||||||
|
@scoped.post("/invitations", response_model=InvitationResponse)
|
||||||
|
async def create_invitation(
|
||||||
|
request: Request,
|
||||||
|
body: CreateInvitationBody,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
household=Depends(get_household_from_slug),
|
||||||
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
token = secrets.token_urlsafe(24)
|
||||||
|
expires_at = (datetime.utcnow() + timedelta(days=14)).isoformat() + "Z"
|
||||||
|
try:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO HouseholdInvitation (household_id, email, invited_by_user_id, token, expires_at, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(household["id"], body.email, user.id, token, expires_at, "pending"),
|
||||||
|
)
|
||||||
|
return InvitationResponse(token=token)
|
||||||
|
except Exception:
|
||||||
|
return error_response(request, 400, "Unable to create invitation")
|
||||||
|
|
||||||
|
|
||||||
|
# Accept invitation (mounted on root router via main.py)
|
||||||
|
@router.post("/invitations/accept")
|
||||||
|
async def accept_invitation(
|
||||||
|
request: Request,
|
||||||
|
body: dict,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
token = body.get("token")
|
||||||
|
if not token:
|
||||||
|
return error_response(request, 400, "Token required")
|
||||||
|
# Lookup invitation
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT id, household_id, status FROM HouseholdInvitation WHERE token = ?",
|
||||||
|
(token,),
|
||||||
|
) as c:
|
||||||
|
row = await c.fetchone()
|
||||||
|
if not row:
|
||||||
|
return error_response(request, 404, "Invitation not found")
|
||||||
|
inv_id = int(row[0])
|
||||||
|
hid = int(row[1])
|
||||||
|
status = row[2]
|
||||||
|
if status != "pending":
|
||||||
|
return error_response(request, 400, "Invitation not pending")
|
||||||
|
# Add membership if not exists
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?)",
|
||||||
|
(user.id, hid, "member"),
|
||||||
|
)
|
||||||
|
# Mark invitation accepted
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE HouseholdInvitation SET status = 'accepted' WHERE id = ?",
|
||||||
|
(inv_id,),
|
||||||
|
)
|
||||||
|
return {"status": "accepted"}
|
||||||
|
|
|
||||||
|
|
@ -228,10 +228,14 @@ Impact on existing routes (exact files to refactor):
|
||||||
- Notes:
|
- Notes:
|
||||||
- Migration added `household_id` columns and indices, enabling next step to filter by household without additional schema changes.
|
- Migration added `household_id` columns and indices, enabling next step to filter by household without additional schema changes.
|
||||||
|
|
||||||
4. **[ ] Implement Household & Invitation Logic**:
|
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.
|
- ✅ Households router implemented for listing and creating households.
|
||||||
- Add the logic for sending invitation emails (this may require a new utility/service for sending emails).
|
- ✅ Invitations API:
|
||||||
- **Acceptance**: Admins can invite by email; invite accept adds user to household; listing households shows membership with roles.
|
- `POST /api/v1/households/{householdSlug}/invitations` (JWT + membership): creates a pending invitation and returns a token.
|
||||||
|
- `POST /api/v1/invitations/accept` (JWT): validates token, adds user as member, marks invitation as accepted.
|
||||||
|
- Tests: `tests/test_invitations_v2.py` cover create + accept flow and membership visibility.
|
||||||
|
- ⏳ Email Delivery: Stub only. Pending adding an email sender utility/service and persistence of delivery state.
|
||||||
|
- **Acceptance**: Admins can invite by email; invite accept adds user to household; listing households shows membership with roles. (Met for API behavior; email sending pending.)
|
||||||
|
|
||||||
5. **[ ] Update OpenAPI Specification**:
|
5. **[ ] Update OpenAPI Specification**:
|
||||||
- ✅ Augmentation updated in `api/openapi.py`:
|
- ✅ Augmentation updated in `api/openapi.py`:
|
||||||
|
|
|
||||||
72
tests/test_invitations_v2.py
Normal file
72
tests/test_invitations_v2.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import unittest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import main
|
||||||
|
from db import connect, create
|
||||||
|
|
||||||
|
|
||||||
|
class TestInvitationsV2(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)
|
||||||
|
|
||||||
|
# Register inviter
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "owner@test.com", "password": "pw", "displayName": "Owner"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
self.owner_headers = {"Authorization": f"Bearer {r.json()['accessToken']}"}
|
||||||
|
|
||||||
|
# Create household
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/v1/households", headers=self.owner_headers, json={"name": "Team"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
self.slug = r.json()["slug"]
|
||||||
|
|
||||||
|
async def asyncTearDown(self):
|
||||||
|
await self.conn.close()
|
||||||
|
main.app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
def test_create_invitation_and_accept(self):
|
||||||
|
# Create invitation
|
||||||
|
r = self.client.post(
|
||||||
|
f"/api/v1/households/{self.slug}/invitations",
|
||||||
|
headers=self.owner_headers,
|
||||||
|
json={"email": "invitee@test.com"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
token = r.json()["token"]
|
||||||
|
assert isinstance(token, str) and len(token) >= 16
|
||||||
|
|
||||||
|
# Register invitee
|
||||||
|
r2 = self.client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "invitee@test.com", "password": "pw", "displayName": "Invitee"},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
invitee_headers = {"Authorization": f"Bearer {r2.json()['accessToken']}"}
|
||||||
|
|
||||||
|
# Accept invitation
|
||||||
|
r3 = self.client.post(
|
||||||
|
"/api/v1/invitations/accept", headers=invitee_headers, json={"token": token}
|
||||||
|
)
|
||||||
|
assert r3.status_code == 200, r3.text
|
||||||
|
body = r3.json()
|
||||||
|
assert body["status"] == "accepted"
|
||||||
|
|
||||||
|
# Invitee should now see household in their list
|
||||||
|
r4 = self.client.get("/api/v1/users/me/households", headers=invitee_headers)
|
||||||
|
assert r4.status_code == 200, r4.text
|
||||||
|
households = r4.json()
|
||||||
|
assert any(h["slug"] == self.slug for h in households)
|
||||||
Loading…
Reference in a new issue