301 lines
12 KiB
Python
301 lines
12 KiB
Python
import io
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from app import db
|
|
from app.models import AuditLog, CheckIn, Event, Guest, SmtpSettings, User, utcnow
|
|
from app.services.email_service import send_invitation
|
|
from app.tasks import send_event_invitations_in_context
|
|
from app.utils.paths import event_photo_folder
|
|
from config import Config
|
|
|
|
|
|
def _user(username, role):
|
|
user = User(username=username, role=role, is_active=True)
|
|
user.set_password("Valid-Password-123")
|
|
db.session.add(user)
|
|
db.session.flush()
|
|
return user
|
|
|
|
|
|
def _guest(event, email="guest@example.com"):
|
|
guest = Guest(
|
|
event_id=event.id,
|
|
department="IT",
|
|
full_name="Тестовый Гость",
|
|
email=email,
|
|
photo_filename="guest.png",
|
|
)
|
|
db.session.add(guest)
|
|
db.session.flush()
|
|
return guest
|
|
|
|
|
|
def test_organizer_cannot_read_another_event_api(app, client):
|
|
with app.app_context():
|
|
owner = _user("owner", "organizer")
|
|
other = _user("other", "organizer")
|
|
own_event = Event(name="Own", created_by_id=owner.id)
|
|
other_event = Event(name="Other", created_by_id=other.id)
|
|
db.session.add_all([own_event, other_event])
|
|
db.session.flush()
|
|
_guest(own_event, "own@example.com")
|
|
_guest(other_event, "other@example.com")
|
|
db.session.commit()
|
|
owner_id = owner.id
|
|
owner_version = owner.session_version
|
|
other_event_id = other_event.id
|
|
|
|
with client.session_transaction() as session:
|
|
session["user_id"] = owner_id
|
|
session["role"] = "organizer"
|
|
session["session_version"] = owner_version
|
|
|
|
response = client.get(f"/api/guests/search?q=Тест&event_id={other_event_id}")
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_private_photo_requires_event_access(app, client):
|
|
with app.app_context():
|
|
owner = _user("owner", "organizer")
|
|
other = _user("other", "organizer")
|
|
event = Event(name="Private", created_by_id=owner.id)
|
|
db.session.add(event)
|
|
db.session.flush()
|
|
guest = _guest(event)
|
|
folder = event_photo_folder(event.id)
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
Image.new("RGB", (10, 10), "white").save(folder / guest.photo_filename)
|
|
db.session.commit()
|
|
other_id = other.id
|
|
other_version = other.session_version
|
|
guest_id = guest.id
|
|
|
|
with client.session_transaction() as session:
|
|
session["user_id"] = other_id
|
|
session["role"] = "organizer"
|
|
session["session_version"] = other_version
|
|
|
|
assert client.get(f"/media/guests/{guest_id}/photo").status_code == 403
|
|
|
|
|
|
def test_staff_access_requires_event_assignment(app, client):
|
|
with app.app_context():
|
|
admin = _user("admin", "admin")
|
|
staff = _user("staff-assigned", "staff")
|
|
event = Event(name="Assigned", created_by_id=admin.id)
|
|
db.session.add(event)
|
|
db.session.flush()
|
|
guest = _guest(event)
|
|
db.session.commit()
|
|
staff_id = staff.id
|
|
staff_version = staff.session_version
|
|
event_id = event.id
|
|
guest_id = guest.id
|
|
|
|
with client.session_transaction() as session:
|
|
session["user_id"] = staff_id
|
|
session["role"] = "staff"
|
|
session["session_version"] = staff_version
|
|
|
|
assert client.get(f"/api/guests/search?q=Тест&event_id={event_id}").status_code == 403
|
|
|
|
with app.app_context():
|
|
event = db.session.get(Event, event_id)
|
|
staff = db.session.get(User, staff_id)
|
|
event.staff_members.append(staff)
|
|
db.session.commit()
|
|
|
|
response = client.get(f"/api/guests/search?q=Тест&event_id={event_id}")
|
|
assert response.status_code == 200
|
|
assert response.get_json()[0]["id"] == guest_id
|
|
|
|
|
|
def test_invalid_image_upload_is_not_saved(app, client):
|
|
with app.app_context():
|
|
organizer = _user("organizer", "organizer")
|
|
event = Event(name="Event", created_by_id=organizer.id)
|
|
db.session.add(event)
|
|
db.session.commit()
|
|
organizer_id = organizer.id
|
|
organizer_version = organizer.session_version
|
|
event_id = event.id
|
|
|
|
with client.session_transaction() as session:
|
|
session["user_id"] = organizer_id
|
|
session["role"] = "organizer"
|
|
session["session_version"] = organizer_version
|
|
session["current_event_id"] = event_id
|
|
|
|
response = client.post(
|
|
"/admin/upload",
|
|
data={"photo_files": (io.BytesIO(b"<script>alert(1)</script>"), "photo.jpg")},
|
|
content_type="multipart/form-data",
|
|
)
|
|
assert response.status_code == 302
|
|
with app.app_context():
|
|
assert not (event_photo_folder(event_id) / "photo.jpg").exists()
|
|
|
|
|
|
def test_mail_password_is_encrypted_at_rest(app):
|
|
with app.app_context():
|
|
settings = SmtpSettings.get_settings()
|
|
settings.set_mail_password("smtp-password")
|
|
db.session.commit()
|
|
raw_value = settings.mail_password
|
|
assert raw_value.startswith("fernet:")
|
|
assert "smtp-password" not in raw_value
|
|
assert settings.get_mail_password() == "smtp-password"
|
|
|
|
|
|
def test_security_headers_are_present(client):
|
|
response = client.get("/login")
|
|
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
|
assert response.headers["X-Frame-Options"] == "DENY"
|
|
assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"]
|
|
|
|
|
|
def test_web_setup_can_be_disabled(app, client):
|
|
app.config["ALLOW_WEB_SETUP"] = False
|
|
assert client.get("/setup").status_code == 404
|
|
|
|
|
|
def test_unsafe_production_config_is_rejected():
|
|
class UnsafeProductionConfig(Config):
|
|
IS_PRODUCTION = True
|
|
SECRET_KEY = "change-me-in-production"
|
|
CREDENTIAL_ENCRYPTION_KEY = "x" * 32
|
|
BASE_URL = "https://events.example.com"
|
|
TRUSTED_HOSTS = ["events.example.com"]
|
|
SQLALCHEMY_DATABASE_URI = "postgresql+psycopg://example"
|
|
RATELIMIT_STORAGE_URI = "redis://example"
|
|
SESSION_COOKIE_SECURE = True
|
|
ENFORCE_HTTPS = True
|
|
ALLOW_WEB_SETUP = False
|
|
|
|
with pytest.raises(RuntimeError, match="SECRET_KEY"):
|
|
UnsafeProductionConfig.validate()
|
|
|
|
|
|
def test_checkin_api_requires_csrf_when_enabled(app, client):
|
|
with app.app_context():
|
|
staff = _user("staff", "staff")
|
|
event = Event(name="Event", created_by_id=staff.id)
|
|
db.session.add(event)
|
|
db.session.flush()
|
|
guest = _guest(event)
|
|
db.session.commit()
|
|
staff_id = staff.id
|
|
staff_version = staff.session_version
|
|
token = guest.token
|
|
|
|
with client.session_transaction() as session:
|
|
session["user_id"] = staff_id
|
|
session["role"] = "staff"
|
|
session["session_version"] = staff_version
|
|
app.config["WTF_CSRF_ENABLED"] = True
|
|
|
|
assert client.post(f"/api/check-in/{token}").status_code == 400
|
|
|
|
|
|
def test_admin_guest_search_and_pagination(app, client):
|
|
with app.app_context():
|
|
organizer = _user("search-owner", "organizer")
|
|
event = Event(name="Search", created_by_id=organizer.id)
|
|
db.session.add(event); db.session.flush()
|
|
for number in range(55):
|
|
guest = _guest(event, f"guest-{number:02d}@example.com")
|
|
guest.full_name = f"Гость {number:02d}"
|
|
db.session.commit()
|
|
user_id, version, event_id = organizer.id, organizer.session_version, event.id
|
|
with client.session_transaction() as session:
|
|
session.update(user_id=user_id, role="organizer", session_version=version, current_event_id=event_id)
|
|
assert client.get("/admin/guests").data.count(b"guest-") == 50
|
|
assert client.get("/admin/guests?page=2").data.count(b"guest-") == 5
|
|
assert b"guest-54@example.com" in client.get("/admin/guests?q=guest-54@example.com").data
|
|
assert client.get("/admin/upload/template").data.startswith(b"PK")
|
|
|
|
|
|
def test_duplicate_checkin_is_rejected_and_can_be_undone(app, client):
|
|
with app.app_context():
|
|
admin = _user("gate-admin", "admin")
|
|
staff = _user("gate-staff", "staff")
|
|
event = Event(name="Gate", created_by_id=admin.id)
|
|
event.staff_members.append(staff)
|
|
db.session.add(event); db.session.flush()
|
|
guest = _guest(event, "gate@example.com")
|
|
db.session.commit()
|
|
staff_id, version, token, guest_id = staff.id, staff.session_version, guest.token, guest.id
|
|
with client.session_transaction() as session:
|
|
session.update(user_id=staff_id, role="staff", session_version=version)
|
|
assert client.post(f"/api/check-in/{token}").status_code == 200
|
|
assert client.post(f"/api/check-in/{token}").status_code == 409
|
|
assert client.post(f"/api/check-in/{token}/undo", json={"reason": "Ошибка оператора"}).status_code == 200
|
|
with app.app_context():
|
|
assert CheckIn.query.filter_by(guest_id=guest_id).count() == 0
|
|
assert AuditLog.query.filter_by(action="check_in_undo", target_id=str(guest_id)).count() == 1
|
|
|
|
|
|
def test_database_rejects_duplicate_checkins(app):
|
|
with app.app_context():
|
|
owner = _user("constraint-owner", "admin")
|
|
event = Event(name="Constraint", created_by_id=owner.id)
|
|
db.session.add(event); db.session.flush()
|
|
guest = _guest(event, "constraint@example.com")
|
|
db.session.flush()
|
|
db.session.add_all([CheckIn(guest_id=guest.id, event_id=event.id), CheckIn(guest_id=guest.id, event_id=event.id)])
|
|
with pytest.raises(IntegrityError):
|
|
db.session.commit()
|
|
db.session.rollback()
|
|
|
|
|
|
def test_public_invitation_rsvp_calendar_and_ticket(app, client, monkeypatch, tmp_path):
|
|
with app.app_context():
|
|
owner = _user("rsvp-owner", "organizer")
|
|
event = Event(
|
|
name="RSVP Event", created_by_id=owner.id, event_date=utcnow(),
|
|
event_end_date=utcnow(), rsvp_enabled=True,
|
|
)
|
|
db.session.add(event); db.session.flush()
|
|
guest = _guest(event, "rsvp@example.com")
|
|
db.session.commit()
|
|
token, guest_id = guest.token, guest.id
|
|
|
|
assert client.get(f"/invite/{token}").status_code == 200
|
|
response = client.post(f"/invite/{token}", data={
|
|
"rsvp_status": "confirmed",
|
|
"dietary_requirements": "Без орехов",
|
|
"rsvp_comment": "Буду вовремя",
|
|
})
|
|
assert response.status_code == 302
|
|
calendar = client.get(f"/invite/{token}/calendar.ics")
|
|
assert calendar.status_code == 200
|
|
assert b"BEGIN:VCALENDAR" in calendar.data
|
|
|
|
ticket_path = tmp_path / "ticket.png"
|
|
Image.new("RGB", (10, 10), "white").save(ticket_path)
|
|
monkeypatch.setattr("app.routes.generate_ticket_image", lambda *_args, **_kwargs: ticket_path)
|
|
assert client.get(f"/invite/{token}/ticket").status_code == 200
|
|
with app.app_context():
|
|
updated = db.session.get(Guest, guest_id)
|
|
assert updated.rsvp_status == "confirmed"
|
|
assert updated.dietary_requirements == "Без орехов"
|
|
|
|
|
|
def test_invitation_delivery_and_worker_status(app, monkeypatch):
|
|
with app.app_context():
|
|
owner = _user("worker-owner", "organizer")
|
|
event = Event(name="Worker", created_by_id=owner.id)
|
|
db.session.add(event); db.session.flush()
|
|
guest = _guest(event, "worker@example.com")
|
|
db.session.commit()
|
|
monkeypatch.setattr("app.services.email_service.generate_ticket_image", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr("app.services.email_service._send_email", lambda **_kwargs: None)
|
|
result = send_event_invitations_in_context(event.id, owner.id)
|
|
db.session.refresh(guest)
|
|
assert result["sent"] == 1
|
|
assert guest.invitation_status == "sent"
|
|
assert guest.invitation_attempts == 1
|