|
1 | 1 | import pytest |
2 | 2 | from datetime import datetime, timedelta, UTC |
3 | 3 | from unittest.mock import MagicMock |
4 | | -from sqlmodel import Session, select |
| 4 | +from sqlmodel import Session, select, col |
5 | 5 | from tests.conftest import SetupError |
6 | 6 | from utils.core.models import Role, Permission, User, Invitation, Organization, Account |
7 | 7 | from utils.core.enums import ValidPermissions |
@@ -498,24 +498,124 @@ def test_create_invitation_for_existing_member( |
498 | 498 | ) # Conflict - UserIsAlreadyMemberError |
499 | 499 |
|
500 | 500 |
|
501 | | -def test_create_invitation_duplicate_active( |
502 | | - auth_client, inviter_user: User, existing_invitation: Invitation |
| 501 | +def test_create_invitation_resend_replaces_active_invitation( |
| 502 | + auth_client, |
| 503 | + inviter_user: User, |
| 504 | + existing_invitation: Invitation, |
| 505 | + session: Session, |
| 506 | + mock_resend_send, |
503 | 507 | ): |
504 | | - """Test that creating a duplicate active invitation fails.""" |
| 508 | + """Re-inviting the same email replaces the pending invite and invalidates the old token.""" |
505 | 509 | assert existing_invitation.organization_id is not None |
506 | 510 | assert existing_invitation.role_id is not None |
| 511 | + old_token = existing_invitation.token |
| 512 | + old_id = existing_invitation.id |
| 513 | + invitee_email = existing_invitation.invitee_email |
| 514 | + organization_id = existing_invitation.organization_id |
| 515 | + |
507 | 516 | response = auth_client.post( |
508 | 517 | app.url_path_for("create_invitation"), |
509 | 518 | data={ |
510 | | - "invitee_email": existing_invitation.invitee_email, # Same email |
| 519 | + "invitee_email": invitee_email, |
| 520 | + "role_id": str(existing_invitation.role_id), |
| 521 | + "organization_id": str(organization_id), |
| 522 | + }, |
| 523 | + follow_redirects=False, |
| 524 | + ) |
| 525 | + |
| 526 | + assert response.status_code == 303, response.text |
| 527 | + |
| 528 | + session.expire_all() |
| 529 | + assert session.get(Invitation, old_id) is None |
| 530 | + |
| 531 | + new_invitation = session.exec( |
| 532 | + select(Invitation).where( |
| 533 | + Invitation.invitee_email == invitee_email, |
| 534 | + Invitation.organization_id == organization_id, |
| 535 | + col(Invitation.used).is_(False), |
| 536 | + ) |
| 537 | + ).first() |
| 538 | + assert new_invitation is not None |
| 539 | + assert new_invitation.token != old_token |
| 540 | + |
| 541 | + accept_response = auth_client.get( |
| 542 | + app.url_path_for("accept_invitation"), |
| 543 | + params={"token": old_token}, |
| 544 | + follow_redirects=False, |
| 545 | + ) |
| 546 | + assert accept_response.status_code == 404 |
| 547 | + assert "no longer valid" in accept_response.text.lower() |
| 548 | + |
| 549 | + |
| 550 | +def test_create_invitation_resend_after_expired_pending_invite( |
| 551 | + auth_client, |
| 552 | + inviter_user: User, |
| 553 | + expired_invitation: Invitation, |
| 554 | + session: Session, |
| 555 | + mock_resend_send, |
| 556 | +): |
| 557 | + """Re-inviting after expiry succeeds even though the old row remains used=False in DB.""" |
| 558 | + assert expired_invitation.organization_id is not None |
| 559 | + assert expired_invitation.role_id is not None |
| 560 | + expired_id = expired_invitation.id |
| 561 | + invitee_email = expired_invitation.invitee_email |
| 562 | + organization_id = expired_invitation.organization_id |
| 563 | + old_token = expired_invitation.token |
| 564 | + |
| 565 | + response = auth_client.post( |
| 566 | + app.url_path_for("create_invitation"), |
| 567 | + data={ |
| 568 | + "invitee_email": invitee_email, |
| 569 | + "role_id": str(expired_invitation.role_id), |
| 570 | + "organization_id": str(organization_id), |
| 571 | + }, |
| 572 | + follow_redirects=False, |
| 573 | + ) |
| 574 | + |
| 575 | + assert response.status_code == 303, response.text |
| 576 | + session.expire_all() |
| 577 | + assert session.get(Invitation, expired_id) is None |
| 578 | + |
| 579 | + replacement = session.exec( |
| 580 | + select(Invitation).where( |
| 581 | + Invitation.invitee_email == invitee_email, |
| 582 | + Invitation.organization_id == organization_id, |
| 583 | + col(Invitation.used).is_(False), |
| 584 | + ) |
| 585 | + ).first() |
| 586 | + assert replacement is not None |
| 587 | + assert replacement.token != old_token |
| 588 | + |
| 589 | + |
| 590 | +def test_create_invitation_resend_email_failure_restores_old_invite( |
| 591 | + auth_client, |
| 592 | + inviter_user: User, |
| 593 | + existing_invitation: Invitation, |
| 594 | + session: Session, |
| 595 | + mock_resend_send, |
| 596 | +): |
| 597 | + """Failed resend rolls back both delete and new invite creation.""" |
| 598 | + assert existing_invitation.id is not None |
| 599 | + old_token = existing_invitation.token |
| 600 | + mock_resend_send.side_effect = Exception("Simulated email send failure") |
| 601 | + |
| 602 | + response = auth_client.post( |
| 603 | + app.url_path_for("create_invitation"), |
| 604 | + data={ |
| 605 | + "invitee_email": existing_invitation.invitee_email, |
511 | 606 | "role_id": str(existing_invitation.role_id), |
512 | 607 | "organization_id": str(existing_invitation.organization_id), |
513 | 608 | }, |
| 609 | + follow_redirects=False, |
514 | 610 | ) |
515 | 611 |
|
516 | | - assert response.status_code == 409, ( |
517 | | - f"Expected 409 Conflict, got {response.status_code}. Response: {response.text}" |
518 | | - ) # Conflict - ActiveInvitationExistsError |
| 612 | + assert response.status_code == 500 |
| 613 | + session.expire_all() |
| 614 | + restored = session.exec( |
| 615 | + select(Invitation).where(Invitation.token == old_token) |
| 616 | + ).first() |
| 617 | + assert restored is not None |
| 618 | + assert restored.id == existing_invitation.id |
519 | 619 |
|
520 | 620 |
|
521 | 621 | def test_create_invitation_role_not_found( |
|
0 commit comments