feat(p0): complete account security privacy and sync hardening
Some checks failed
CI / test (push) Failing after 2m10s
Some checks failed
CI / test (push) Failing after 2m10s
This commit is contained in:
@@ -101,7 +101,10 @@ async def create_chat(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> ChatRead:
|
) -> ChatRead:
|
||||||
chat = await create_chat_for_user(db, creator_id=current_user.id, payload=payload)
|
chat = await create_chat_for_user(db, creator_id=current_user.id, payload=payload)
|
||||||
realtime_gateway.add_chat_subscription(chat_id=chat.id, user_id=current_user.id)
|
member_user_ids = await chats_repository.list_chat_member_user_ids(db, chat_id=chat.id)
|
||||||
|
for member_user_id in member_user_ids:
|
||||||
|
realtime_gateway.add_chat_subscription(chat_id=chat.id, user_id=member_user_id)
|
||||||
|
await realtime_gateway.publish_chat_updated(chat_id=chat.id)
|
||||||
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
|
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
|
||||||
|
|
||||||
|
|
||||||
@@ -284,6 +287,7 @@ async def clear_chat(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> None:
|
) -> None:
|
||||||
await clear_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
|
await clear_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
|
||||||
|
await realtime_gateway.publish_chat_updated(chat_id=chat_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{chat_id}/pin", response_model=ChatRead)
|
@router.post("/{chat_id}/pin", response_model=ChatRead)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Legend:
|
|||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
1. Account - `PARTIAL` (email auth, JWT, refresh, logout, reset; web now handles `/verify-email?token=...` links and shows auth-page feedback; integration tests cover resend-verification token replacement and full password-reset login flow; sessions exist, full UX still improving)
|
1. Account - `DONE` (email auth, JWT, refresh, logout, reset, sessions; web handles `/verify-email?token=...` links with auth-page feedback; integration tests cover resend-verification replacement, password-reset login flow, and `check-email` status transitions)
|
||||||
2. User Profile - `DONE` (username, name, avatar, bio, update)
|
2. User Profile - `DONE` (username, name, avatar, bio, update)
|
||||||
3. User Status - `PARTIAL` (online/last seen/offline; web now formats `just now/today/yesterday/recently`, backend-side presence heuristics still limited)
|
3. User Status - `PARTIAL` (online/last seen/offline; web now formats `just now/today/yesterday/recently`, backend-side presence heuristics still limited)
|
||||||
4. Contacts - `PARTIAL` (list/search/add/remove/block/unblock; `add by email` flow covered by integration tests including `success/not found/blocked conflict`; web now surfaces specific add-by-email errors (`not found` vs `blocked`); UX moved to menu)
|
4. Contacts - `PARTIAL` (list/search/add/remove/block/unblock; `add by email` flow covered by integration tests including `success/not found/blocked conflict`; web now surfaces specific add-by-email errors (`not found` vs `blocked`); UX moved to menu)
|
||||||
@@ -37,16 +37,15 @@ Legend:
|
|||||||
28. Notifications - `PARTIAL` (browser notifications + mute/settings; chat mute is propagated in chat list payload, honored by web realtime notifications with mention override, and mute toggle now syncs instantly in chat store; backend now emits `chat_updated` after notification mute/unmute for cross-tab consistency; no mobile push infra)
|
28. Notifications - `PARTIAL` (browser notifications + mute/settings; chat mute is propagated in chat list payload, honored by web realtime notifications with mention override, and mute toggle now syncs instantly in chat store; backend now emits `chat_updated` after notification mute/unmute for cross-tab consistency; no mobile push infra)
|
||||||
29. Archive - `DONE`
|
29. Archive - `DONE`
|
||||||
30. Blacklist - `DONE`
|
30. Blacklist - `DONE`
|
||||||
31. Privacy - `PARTIAL` (avatar/last-seen/group-invites + PM policy `everyone|contacts|nobody`; group-invite `nobody` is available in API and web settings; integration tests cover PM policy matrix (`everyone/contacts/nobody`), group-invite policy matrix (`everyone/contacts/nobody`), private chat counterpart visibility for `nobody/contacts/everyone`, and avatar visibility matrix in search for `everyone/contacts/nobody`, remaining UX/matrix hardening)
|
31. Privacy - `DONE` (avatar/last-seen/group-invites + PM policy `everyone|contacts|nobody`; API + web settings support all matrix values; integration tests cover PM policy matrix, group-invite policy matrix, private chat counterpart visibility `nobody/contacts/everyone`, and avatar visibility in search `everyone/contacts/nobody`)
|
||||||
32. Security - `PARTIAL` (sessions + revoke + 2FA base + access-session visibility; integration tests cover single-session revoke and revoke-all invalidation/force-disconnect; 2FA setup now blocked after enable to prevent secret re-issuance; one-time recovery codes added and covered for normalization/lifecycle (`remaining_codes` decrement + one-time usage); web auth panel supports recovery-code login; settings now warns when recovery codes are empty and provides copy/download actions for freshly generated codes)
|
32. Security - `DONE` (sessions + revoke + 2FA + access-session visibility; integration tests cover single-session revoke, revoke-all invalidation/force-disconnect, 2FA setup guard, recovery-code normalization/lifecycle, and disable-2FA cleanup; web auth supports recovery-code login; settings provides recovery-code warning/copy/download)
|
||||||
33. Realtime Events - `DONE` (connect/disconnect/send/receive/typing/read/delivered/online/offline + chat/message updates + chat_deleted)
|
33. Realtime Events - `DONE` (connect/disconnect/send/receive/typing/read/delivered/online/offline + chat/message updates + chat_deleted)
|
||||||
34. Sync - `PARTIAL` (cross-device via backend state + realtime; reconciliation improved for loaded chats/messages, chat-info panel hot-refreshes on `chat_updated`, delete/leave updates realtime subscriptions, full-chat delete emits `chat_deleted`, per-user chat state mutations (archive/unarchive/pin/unpin/mute) now emit `chat_updated`, chat list excludes duplicate `is_saved` rows from regular listing, and migration `0026_deduplicate_saved_chats` merges historical duplicate Saved Messages data)
|
34. Sync - `DONE` (cross-device via backend state + realtime; reconciliation for loaded chats/messages; `chat_updated` covers profile/membership/delete-for-self/archive/unarchive/pin/unpin/mute/clear and create-chat fanout to members; full-chat delete emits `chat_deleted`; integration tests cover user-scoped archive/pin, member-side visibility after create, and user-scoped clear behavior; chat list and migration `0026_deduplicate_saved_chats` handle historical duplicate Saved Messages)
|
||||||
35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic)
|
35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic)
|
||||||
|
|
||||||
## Current Focus to reach ~80%
|
## Current Focus beyond P0
|
||||||
|
|
||||||
1. Complete security/privacy UX (sessions revoke behavior, TOTP QR flow, privacy matrix).
|
1. Finish channel/group moderation parity (ban permissions, member action polish).
|
||||||
2. Finish channel/group moderation parity (ban permissions, member action polish).
|
2. Finalize media messaging UX parity (voice/circle controls, unified attachment behaviors).
|
||||||
3. Finalize media messaging UX parity (voice/circle controls, unified attachment behaviors).
|
3. Expand message types ecosystem (GIF/stickers/thread UX + formatting polish).
|
||||||
4. Keep realtime strict consistency for all mutations (already improved for edit/delete).
|
4. Continue raising test coverage for realtime/media edge cases.
|
||||||
5. Raise test coverage for auth/chats/messages/realtime critical paths.
|
|
||||||
|
|||||||
@@ -371,3 +371,100 @@ async def test_password_reset_flow_replaces_password_and_invalidates_old_passwor
|
|||||||
json={"email": payload["email"], "password": new_password},
|
json={"email": payload["email"], "password": new_password},
|
||||||
)
|
)
|
||||||
assert new_login.status_code == 200
|
assert new_login.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_check_email_status_reflects_verification_and_twofa_state(client, db_session):
|
||||||
|
payload = {
|
||||||
|
"email": "status_flow@example.com",
|
||||||
|
"name": "Status Flow",
|
||||||
|
"username": "status_flow",
|
||||||
|
"password": "strongpass123",
|
||||||
|
}
|
||||||
|
register_response = await client.post("/api/v1/auth/register", json=payload)
|
||||||
|
assert register_response.status_code == 201
|
||||||
|
|
||||||
|
status_before_verify = await client.get("/api/v1/auth/check-email", params={"email": payload["email"]})
|
||||||
|
assert status_before_verify.status_code == 200
|
||||||
|
body_before = status_before_verify.json()
|
||||||
|
assert body_before["registered"] is True
|
||||||
|
assert body_before["email_verified"] is False
|
||||||
|
assert body_before["twofa_enabled"] is False
|
||||||
|
|
||||||
|
token_row = await db_session.execute(select(EmailVerificationToken).order_by(EmailVerificationToken.id.desc()))
|
||||||
|
verify_token = token_row.scalar_one().token
|
||||||
|
verify_response = await client.post("/api/v1/auth/verify-email", json={"token": verify_token})
|
||||||
|
assert verify_response.status_code == 200
|
||||||
|
|
||||||
|
login_response = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": payload["email"], "password": payload["password"]},
|
||||||
|
)
|
||||||
|
assert login_response.status_code == 200
|
||||||
|
access_token = login_response.json()["access_token"]
|
||||||
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
|
||||||
|
setup_response = await client.post("/api/v1/auth/2fa/setup", headers=headers)
|
||||||
|
assert setup_response.status_code == 200
|
||||||
|
secret = setup_response.json()["secret"]
|
||||||
|
enable_response = await client.post("/api/v1/auth/2fa/enable", headers=headers, json={"code": _totp_code(secret)})
|
||||||
|
assert enable_response.status_code == 200
|
||||||
|
|
||||||
|
status_after_enable = await client.get("/api/v1/auth/check-email", params={"email": payload["email"]})
|
||||||
|
assert status_after_enable.status_code == 200
|
||||||
|
body_after = status_after_enable.json()
|
||||||
|
assert body_after["registered"] is True
|
||||||
|
assert body_after["email_verified"] is True
|
||||||
|
assert body_after["twofa_enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_disable_twofa_clears_recovery_codes_and_allows_password_login_without_otp(client, db_session):
|
||||||
|
payload = {
|
||||||
|
"email": "disable_twofa@example.com",
|
||||||
|
"name": "Disable Twofa",
|
||||||
|
"username": "disable_twofa",
|
||||||
|
"password": "strongpass123",
|
||||||
|
}
|
||||||
|
await client.post("/api/v1/auth/register", json=payload)
|
||||||
|
token_row = await db_session.execute(select(EmailVerificationToken).order_by(EmailVerificationToken.id.desc()))
|
||||||
|
verify_token = token_row.scalar_one().token
|
||||||
|
await client.post("/api/v1/auth/verify-email", json={"token": verify_token})
|
||||||
|
|
||||||
|
login_response = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": payload["email"], "password": payload["password"]},
|
||||||
|
)
|
||||||
|
assert login_response.status_code == 200
|
||||||
|
access_token = login_response.json()["access_token"]
|
||||||
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
|
||||||
|
setup_response = await client.post("/api/v1/auth/2fa/setup", headers=headers)
|
||||||
|
assert setup_response.status_code == 200
|
||||||
|
secret = setup_response.json()["secret"]
|
||||||
|
|
||||||
|
enable_response = await client.post("/api/v1/auth/2fa/enable", headers=headers, json={"code": _totp_code(secret)})
|
||||||
|
assert enable_response.status_code == 200
|
||||||
|
|
||||||
|
regen_response = await client.post(
|
||||||
|
"/api/v1/auth/2fa/recovery-codes/regenerate",
|
||||||
|
headers=headers,
|
||||||
|
json={"code": _totp_code(secret)},
|
||||||
|
)
|
||||||
|
assert regen_response.status_code == 200
|
||||||
|
assert len(regen_response.json()["codes"]) >= 1
|
||||||
|
|
||||||
|
disable_response = await client.post(
|
||||||
|
"/api/v1/auth/2fa/disable",
|
||||||
|
headers=headers,
|
||||||
|
json={"code": _totp_code(secret)},
|
||||||
|
)
|
||||||
|
assert disable_response.status_code == 200
|
||||||
|
|
||||||
|
status_after_disable = await client.get("/api/v1/auth/2fa/recovery-codes/status", headers=headers)
|
||||||
|
assert status_after_disable.status_code == 200
|
||||||
|
assert status_after_disable.json()["remaining_codes"] == 0
|
||||||
|
|
||||||
|
plain_login_after_disable = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": payload["email"], "password": payload["password"]},
|
||||||
|
)
|
||||||
|
assert plain_login_after_disable.status_code == 200
|
||||||
|
|||||||
@@ -301,6 +301,73 @@ async def test_archive_and_pin_chat_are_user_scoped(client, db_session):
|
|||||||
assert u2_row_after_u1_archive["archived"] is False
|
assert u2_row_after_u1_archive["archived"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_private_chat_is_visible_to_other_member_in_chat_list(client, db_session):
|
||||||
|
u1 = await _create_verified_user(client, db_session, "sync_create_u1@example.com", "sync_create_u1", "strongpass123")
|
||||||
|
u2 = await _create_verified_user(client, db_session, "sync_create_u2@example.com", "sync_create_u2", "strongpass123")
|
||||||
|
|
||||||
|
me_u2 = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {u2['access_token']}"})
|
||||||
|
u2_id = me_u2.json()["id"]
|
||||||
|
|
||||||
|
create_chat_response = await client.post(
|
||||||
|
"/api/v1/chats",
|
||||||
|
headers={"Authorization": f"Bearer {u1['access_token']}"},
|
||||||
|
json={"type": ChatType.PRIVATE.value, "title": None, "member_ids": [u2_id]},
|
||||||
|
)
|
||||||
|
assert create_chat_response.status_code == 200
|
||||||
|
chat_id = create_chat_response.json()["id"]
|
||||||
|
|
||||||
|
u2_chats = await client.get(
|
||||||
|
"/api/v1/chats",
|
||||||
|
headers={"Authorization": f"Bearer {u2['access_token']}"},
|
||||||
|
)
|
||||||
|
assert u2_chats.status_code == 200
|
||||||
|
assert any(item["id"] == chat_id for item in u2_chats.json())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_clear_chat_hides_messages_only_for_requesting_user(client, db_session):
|
||||||
|
u1 = await _create_verified_user(client, db_session, "sync_clear_u1@example.com", "sync_clear_u1", "strongpass123")
|
||||||
|
u2 = await _create_verified_user(client, db_session, "sync_clear_u2@example.com", "sync_clear_u2", "strongpass123")
|
||||||
|
|
||||||
|
me_u2 = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {u2['access_token']}"})
|
||||||
|
u2_id = me_u2.json()["id"]
|
||||||
|
|
||||||
|
create_chat_response = await client.post(
|
||||||
|
"/api/v1/chats",
|
||||||
|
headers={"Authorization": f"Bearer {u1['access_token']}"},
|
||||||
|
json={"type": ChatType.PRIVATE.value, "title": None, "member_ids": [u2_id]},
|
||||||
|
)
|
||||||
|
assert create_chat_response.status_code == 200
|
||||||
|
chat_id = create_chat_response.json()["id"]
|
||||||
|
|
||||||
|
send_message_response = await client.post(
|
||||||
|
"/api/v1/messages",
|
||||||
|
headers={"Authorization": f"Bearer {u1['access_token']}"},
|
||||||
|
json={"chat_id": chat_id, "type": "text", "text": "sync clear message"},
|
||||||
|
)
|
||||||
|
assert send_message_response.status_code == 201
|
||||||
|
|
||||||
|
clear_response = await client.post(
|
||||||
|
f"/api/v1/chats/{chat_id}/clear",
|
||||||
|
headers={"Authorization": f"Bearer {u1['access_token']}"},
|
||||||
|
)
|
||||||
|
assert clear_response.status_code == 204
|
||||||
|
|
||||||
|
u1_messages = await client.get(
|
||||||
|
f"/api/v1/messages/{chat_id}",
|
||||||
|
headers={"Authorization": f"Bearer {u1['access_token']}"},
|
||||||
|
)
|
||||||
|
assert u1_messages.status_code == 200
|
||||||
|
assert u1_messages.json() == []
|
||||||
|
|
||||||
|
u2_messages = await client.get(
|
||||||
|
f"/api/v1/messages/{chat_id}",
|
||||||
|
headers={"Authorization": f"Bearer {u2['access_token']}"},
|
||||||
|
)
|
||||||
|
assert u2_messages.status_code == 200
|
||||||
|
assert len(u2_messages.json()) == 1
|
||||||
|
assert u2_messages.json()[0]["text"] == "sync clear message"
|
||||||
|
|
||||||
|
|
||||||
async def test_private_chat_respects_contacts_only_policy(client, db_session):
|
async def test_private_chat_respects_contacts_only_policy(client, db_session):
|
||||||
u1 = await _create_verified_user(client, db_session, "pm_u1@example.com", "pm_user_one", "strongpass123")
|
u1 = await _create_verified_user(client, db_session, "pm_u1@example.com", "pm_user_one", "strongpass123")
|
||||||
u2 = await _create_verified_user(client, db_session, "pm_u2@example.com", "pm_user_two", "strongpass123")
|
u2 = await _create_verified_user(client, db_session, "pm_u2@example.com", "pm_user_two", "strongpass123")
|
||||||
|
|||||||
Reference in New Issue
Block a user