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)
|
||||
async def whoami(household=Depends(get_household_from_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:
|
||||
- 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).
|
||||
- **Acceptance**: Admins can invite by email; invite accept adds user to household; listing households shows membership with roles.
|
||||
4. **[~] Implement Household & Invitation Logic**:
|
||||
- ✅ Households router implemented for listing and creating households.
|
||||
- ✅ Invitations API:
|
||||
- `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**:
|
||||
- ✅ 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