Files
InviteMe/tests/test_security.py
T
2026-09-01 11:04:21 +03:00

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