style: formater le depot avec ruff format
QUA-002, premiere moitie. **Ce commit ne fait que reformater** : aucun changement de comportement, aucune ligne de logique touchee. 72 fichiers, 4 restaient deja conformes. Il est isole exprès, pour que `git log -p` sur les commits voisins reste lisible. `quote-style = "preserve"` etait deja pose dans pyproject.toml, ce qui evite le brassage guillemets simples / doubles : le diff porte sur les retours a la ligne, l indentation des appels longs et les virgules finales, pas sur le style de chaine. Verification : 263 tests passent avant et apres, ruff check propre. L activation en CI arrive dans le commit suivant, separement, pour que ce diff-ci ne contienne rien d autre. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -183,6 +183,7 @@ def as_role(app, client, make_user, login):
|
||||
user_id = make_user(role, **kwargs)
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
username = _db.session.get(User, user_id).username
|
||||
response = login(username)
|
||||
assert response.status_code in (301, 302), (
|
||||
|
||||
+60
-22
@@ -106,10 +106,17 @@ class TestAdministrativeEvents:
|
||||
def test_account_creation_is_recorded(self, client, as_role, auth_log):
|
||||
as_role('admin')
|
||||
|
||||
client.post('/users/create', data={
|
||||
'username': 'newcoach', 'email': '[email protected]',
|
||||
'password': 'Password123', 'full_name': 'New Coach', 'role': 'coach',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
'/users/create',
|
||||
data={
|
||||
'username': 'newcoach',
|
||||
'email': '[email protected]',
|
||||
'password': 'Password123',
|
||||
'full_name': 'New Coach',
|
||||
'role': 'coach',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
assert _has_event(auth_log, 'account.created_by_admin')
|
||||
|
||||
@@ -117,10 +124,16 @@ class TestAdministrativeEvents:
|
||||
target_id = make_user('player')
|
||||
as_role('admin')
|
||||
|
||||
client.post(f'/users/{target_id}/edit', data={
|
||||
'full_name': 'Promoted', 'email': '[email protected]t',
|
||||
'role': 'coach', 'is_active_account': 'on',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
f'/users/{target_id}/edit',
|
||||
data={
|
||||
'full_name': 'Promoted',
|
||||
'email': '[email protected]',
|
||||
'role': 'coach',
|
||||
'is_active_account': 'on',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
message = next(m for m in _events(auth_log) if 'event=account.role_changed' in m)
|
||||
assert 'previous_role=player' in message
|
||||
@@ -135,17 +148,21 @@ class TestAdministrativeEvents:
|
||||
|
||||
assert _has_event(auth_log, 'account.deleted')
|
||||
|
||||
def test_a_self_service_password_change_is_recorded(
|
||||
self, app, client, as_role, auth_log
|
||||
):
|
||||
def test_a_self_service_password_change_is_recorded(self, app, client, as_role, auth_log):
|
||||
user_id = as_role('player')
|
||||
with app.app_context():
|
||||
username = db.session.get(User, user_id).username
|
||||
|
||||
client.post('/users/profile/edit', data={
|
||||
'username': username, 'full_name': 'Same Name',
|
||||
'email': '[email protected]', 'password': 'BrandNew123',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
'/users/profile/edit',
|
||||
data={
|
||||
'username': username,
|
||||
'full_name': 'Same Name',
|
||||
'email': '[email protected]',
|
||||
'password': 'BrandNew123',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
assert _has_event(auth_log, 'account.password_changed')
|
||||
|
||||
@@ -162,8 +179,13 @@ class TestRedaction:
|
||||
from app.logging_config import SensitiveDataFilter
|
||||
|
||||
record = logging.LogRecord(
|
||||
'test', logging.ERROR, 'x.py', 1,
|
||||
'Database failure: %s', ('password=hunter2 host=db.internal',), None,
|
||||
'test',
|
||||
logging.ERROR,
|
||||
'x.py',
|
||||
1,
|
||||
'Database failure: %s',
|
||||
('password=hunter2 host=db.internal',),
|
||||
None,
|
||||
)
|
||||
SensitiveDataFilter().filter(record)
|
||||
|
||||
@@ -175,8 +197,13 @@ class TestRedaction:
|
||||
from app.logging_config import SensitiveDataFilter
|
||||
|
||||
record = logging.LogRecord(
|
||||
'test', logging.INFO, 'x.py', 1,
|
||||
'Calling Discord with %s', ('Bearer abcdef123456',), None,
|
||||
'test',
|
||||
logging.INFO,
|
||||
'x.py',
|
||||
1,
|
||||
'Calling Discord with %s',
|
||||
('Bearer abcdef123456',),
|
||||
None,
|
||||
)
|
||||
SensitiveDataFilter().filter(record)
|
||||
|
||||
@@ -186,8 +213,13 @@ class TestRedaction:
|
||||
from app.logging_config import SensitiveDataFilter
|
||||
|
||||
record = logging.LogRecord(
|
||||
'test', logging.INFO, 'x.py', 1,
|
||||
'event=login.success username=%s', ('alice',), None,
|
||||
'test',
|
||||
logging.INFO,
|
||||
'x.py',
|
||||
1,
|
||||
'event=login.success username=%s',
|
||||
('alice',),
|
||||
None,
|
||||
)
|
||||
SensitiveDataFilter().filter(record)
|
||||
|
||||
@@ -197,6 +229,12 @@ class TestRedaction:
|
||||
from app.logging_config import SensitiveDataFilter
|
||||
|
||||
record = logging.LogRecord(
|
||||
'test', logging.INFO, 'x.py', 1, 'broken %d', ('not-a-number',), None,
|
||||
'test',
|
||||
logging.INFO,
|
||||
'x.py',
|
||||
1,
|
||||
'broken %d',
|
||||
('not-a-number',),
|
||||
None,
|
||||
)
|
||||
assert SensitiveDataFilter().filter(record) is True
|
||||
|
||||
+22
-19
@@ -121,20 +121,26 @@ class TestLogout:
|
||||
with app_with_csrf.app_context():
|
||||
from app.extensions import hash_password
|
||||
from app.models import Player
|
||||
user = Player(username='navtest', password_hash=hash_password('Password123'),
|
||||
role='player', full_name='Nav Test', email='[email protected]')
|
||||
|
||||
user = Player(
|
||||
username='navtest',
|
||||
password_hash=hash_password('Password123'),
|
||||
role='player',
|
||||
full_name='Nav Test',
|
||||
email='[email protected]',
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
page = client.get('/auth/login').get_data(as_text=True)
|
||||
token = re.search(r'name="csrf_token" value="([^"]+)"', page).group(1)
|
||||
client.post('/auth/login', data={'username': 'navtest',
|
||||
'password': 'Password123',
|
||||
'csrf_token': token})
|
||||
client.post(
|
||||
'/auth/login',
|
||||
data={'username': 'navtest', 'password': 'Password123', 'csrf_token': token},
|
||||
)
|
||||
|
||||
body = client.get('/users/profile').get_data(as_text=True)
|
||||
form = re.search(
|
||||
r'<form method="POST" action="/auth/logout".*?</form>', body, re.S)
|
||||
form = re.search(r'<form method="POST" action="/auth/logout".*?</form>', body, re.S)
|
||||
assert form is not None, 'no logout form in the navigation'
|
||||
assert 'name="csrf_token"' in form.group(0)
|
||||
|
||||
@@ -150,9 +156,7 @@ class TestLoginRejection:
|
||||
response = client.get('/users/profile', follow_redirects=False)
|
||||
assert response.status_code in (301, 302)
|
||||
|
||||
def test_login_failure_message_does_not_reveal_account_existence(
|
||||
self, client, make_user, app
|
||||
):
|
||||
def test_login_failure_message_does_not_reveal_account_existence(self, client, make_user, app):
|
||||
"""Compares the rendered flash messages rather than looking for a
|
||||
known substring: the site is served in French by default, so an
|
||||
English marker would silently match nothing on both sides and make
|
||||
@@ -171,9 +175,7 @@ class TestLoginRejection:
|
||||
|
||||
assert failure_message(username) == failure_message('no-such-account')
|
||||
|
||||
def test_the_message_stays_the_same_past_the_attempt_threshold(
|
||||
self, client, make_user, app
|
||||
):
|
||||
def test_the_message_stays_the_same_past_the_attempt_threshold(self, client, make_user, app):
|
||||
"""The tally used to be counted out loud — "3 attempt(s) remaining"
|
||||
— which is the same disclosure, spread over five requests."""
|
||||
user_id = make_user('player')
|
||||
@@ -200,13 +202,13 @@ class TestFailedAttemptThrottle:
|
||||
@staticmethod
|
||||
def _exhaust(client, username, times=6):
|
||||
for _ in range(times):
|
||||
client.post('/auth/login',
|
||||
data={'username': username, 'password': 'WrongPassword1'},
|
||||
follow_redirects=True)
|
||||
client.post(
|
||||
'/auth/login',
|
||||
data={'username': username, 'password': 'WrongPassword1'},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
def test_the_owner_still_gets_in_after_the_threshold(
|
||||
self, app, client, make_user, login
|
||||
):
|
||||
def test_the_owner_still_gets_in_after_the_threshold(self, app, client, make_user, login):
|
||||
user_id = make_user('player')
|
||||
with app.app_context():
|
||||
username = db.session.get(User, user_id).username
|
||||
@@ -294,6 +296,7 @@ class TestRedirectValidation:
|
||||
|
||||
with app.test_request_context('/auth/login'):
|
||||
from flask import request
|
||||
|
||||
assert is_safe_url(f'http://{request.host}/dashboard')
|
||||
|
||||
def test_the_login_redirect_refuses_to_leave_the_site(self, client, make_user, app):
|
||||
|
||||
+103
-66
@@ -61,9 +61,7 @@ class TestVerticalAccess:
|
||||
def test_only_admin_reaches_user_management(self, client, as_role, role, route):
|
||||
as_role(role)
|
||||
response = client.get(route, follow_redirects=False)
|
||||
assert _redirected(response), (
|
||||
f'{role} reached {route}, which is meant to be admin-only'
|
||||
)
|
||||
assert _redirected(response), f'{role} reached {route}, which is meant to be admin-only'
|
||||
|
||||
def test_admin_reaches_user_management(self, client, as_role):
|
||||
as_role('admin')
|
||||
@@ -106,8 +104,10 @@ class TestHorizontalAccess:
|
||||
owner_id = make_user('player')
|
||||
with app.app_context():
|
||||
slot = PlayerDisponibility(
|
||||
player_id=owner_id, day_of_week=1,
|
||||
start_time=time(10, 0), end_time=time(10, 30),
|
||||
player_id=owner_id,
|
||||
day_of_week=1,
|
||||
start_time=time(10, 0),
|
||||
end_time=time(10, 30),
|
||||
)
|
||||
db.session.add(slot)
|
||||
db.session.commit()
|
||||
@@ -134,8 +134,11 @@ class TestNestedResourceOwnership:
|
||||
|
||||
with app.app_context():
|
||||
tryout = Tryout(
|
||||
title=title, game='Valorant', date=date(2030, 1, 1),
|
||||
created_by=owner_id, status='upcoming',
|
||||
title=title,
|
||||
game='Valorant',
|
||||
date=date(2030, 1, 1),
|
||||
created_by=owner_id,
|
||||
status='upcoming',
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
@@ -144,9 +147,7 @@ class TestNestedResourceOwnership:
|
||||
db.session.commit()
|
||||
return tryout.id, team.id
|
||||
|
||||
def test_cannot_add_a_player_to_a_team_of_another_tryout(
|
||||
self, app, client, as_role, make_user
|
||||
):
|
||||
def test_cannot_add_a_player_to_a_team_of_another_tryout(self, app, client, as_role, make_user):
|
||||
from app.models import TeamMember, TryoutRegistration
|
||||
|
||||
other_admin = make_user('admin')
|
||||
@@ -157,8 +158,7 @@ class TestNestedResourceOwnership:
|
||||
|
||||
player_id = make_user('player')
|
||||
with app.app_context():
|
||||
db.session.add(TryoutRegistration(
|
||||
tryout_id=own_tryout_id, player_id=player_id))
|
||||
db.session.add(TryoutRegistration(tryout_id=own_tryout_id, player_id=player_id))
|
||||
db.session.commit()
|
||||
|
||||
response = client.post(
|
||||
@@ -167,15 +167,11 @@ class TestNestedResourceOwnership:
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 404, (
|
||||
'a team belonging to another tryout was accepted'
|
||||
)
|
||||
assert response.status_code == 404, 'a team belonging to another tryout was accepted'
|
||||
with app.app_context():
|
||||
assert TeamMember.query.filter_by(team_id=foreign_team_id).count() == 0
|
||||
|
||||
def test_cannot_add_a_player_who_is_not_registered(
|
||||
self, app, client, as_role, make_user
|
||||
):
|
||||
def test_cannot_add_a_player_who_is_not_registered(self, app, client, as_role, make_user):
|
||||
from app.models import TeamMember
|
||||
|
||||
manager_id = as_role('manager')
|
||||
@@ -191,9 +187,7 @@ class TestNestedResourceOwnership:
|
||||
with app.app_context():
|
||||
assert TeamMember.query.filter_by(team_id=team_id).count() == 0
|
||||
|
||||
def test_a_registered_player_can_still_be_added(
|
||||
self, app, client, as_role, make_user
|
||||
):
|
||||
def test_a_registered_player_can_still_be_added(self, app, client, as_role, make_user):
|
||||
"""Guard against over-correcting: the normal path must keep working."""
|
||||
from app.models import TeamMember, TryoutRegistration
|
||||
|
||||
@@ -243,11 +237,15 @@ class TestInputValidation:
|
||||
user_id = as_role('player')
|
||||
payload = '<img src=x onerror=alert(1)>'
|
||||
|
||||
client.post('/users/profile/edit', data={
|
||||
'username': payload,
|
||||
'full_name': 'Legit Name',
|
||||
'email': '[email protected]',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
'/users/profile/edit',
|
||||
data={
|
||||
'username': payload,
|
||||
'full_name': 'Legit Name',
|
||||
'email': '[email protected]',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
assert db.session.get(User, user_id).username != payload
|
||||
@@ -257,12 +255,16 @@ class TestInputValidation:
|
||||
with app.app_context():
|
||||
before = db.session.get(User, user_id).password_hash
|
||||
|
||||
client.post('/users/profile/edit', data={
|
||||
'username': _username(app, user_id),
|
||||
'full_name': 'Legit Name',
|
||||
'email': '[email protected]',
|
||||
'password': 'a',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
'/users/profile/edit',
|
||||
data={
|
||||
'username': _username(app, user_id),
|
||||
'full_name': 'Legit Name',
|
||||
'email': '[email protected]',
|
||||
'password': 'a',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
assert db.session.get(User, user_id).password_hash == before, (
|
||||
@@ -272,13 +274,17 @@ class TestInputValidation:
|
||||
def test_create_user_enforces_the_password_policy(self, app, client, as_role):
|
||||
as_role('admin')
|
||||
|
||||
client.post('/users/create', data={
|
||||
'username': 'weakling',
|
||||
'email': '[email protected]',
|
||||
'password': 'a',
|
||||
'full_name': 'Weak Account',
|
||||
'role': 'admin',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
'/users/create',
|
||||
data={
|
||||
'username': 'weakling',
|
||||
'email': 'weak@example.test',
|
||||
'password': 'a',
|
||||
'full_name': 'Weak Account',
|
||||
'role': 'admin',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
created = User.query.filter_by(username='weakling').first()
|
||||
@@ -292,11 +298,15 @@ class TestInputValidation:
|
||||
with app.app_context():
|
||||
taken = db.session.get(User, other_id).email
|
||||
|
||||
response = client.post(f'/users/{target_id}/edit', data={
|
||||
'full_name': 'Target',
|
||||
'email': taken,
|
||||
'role': 'player',
|
||||
}, follow_redirects=False)
|
||||
response = client.post(
|
||||
f'/users/{target_id}/edit',
|
||||
data={
|
||||
'full_name': 'Target',
|
||||
'email': taken,
|
||||
'role': 'player',
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code < 500, 'duplicate email produced a server error'
|
||||
|
||||
@@ -305,18 +315,21 @@ class TestAdminSafety:
|
||||
def test_the_last_admin_cannot_demote_itself(self, app, client, as_role):
|
||||
admin_id = as_role('admin')
|
||||
|
||||
client.post(f'/users/{admin_id}/edit', data={
|
||||
'full_name': 'Admin',
|
||||
'email': '[email protected]',
|
||||
'role': 'player',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
f'/users/{admin_id}/edit',
|
||||
data={
|
||||
'full_name': 'Admin',
|
||||
'email': '[email protected]',
|
||||
'role': 'player',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
assert db.session.get(User, admin_id).role == 'admin', (
|
||||
'the only administrator demoted itself; no interface can undo this'
|
||||
)
|
||||
|
||||
|
||||
def test_an_admin_cannot_change_its_own_role_even_with_others_present(
|
||||
self, app, client, as_role, make_user
|
||||
):
|
||||
@@ -324,9 +337,15 @@ class TestAdminSafety:
|
||||
make_user('admin')
|
||||
admin_id = as_role('admin')
|
||||
|
||||
client.post(f'/users/{admin_id}/edit', data={
|
||||
'full_name': 'Admin', 'email': '[email protected]', 'role': 'player',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
f'/users/{admin_id}/edit',
|
||||
data={
|
||||
'full_name': 'Admin',
|
||||
'email': '[email protected]',
|
||||
'role': 'player',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
assert db.session.get(User, admin_id).role == 'admin'
|
||||
@@ -336,10 +355,16 @@ class TestAdminSafety:
|
||||
other_id = make_user('admin')
|
||||
as_role('admin')
|
||||
|
||||
client.post(f'/users/{other_id}/edit', data={
|
||||
'full_name': 'Other', 'email': '[email protected]t',
|
||||
'role': 'coach', 'is_active_account': 'on',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
f'/users/{other_id}/edit',
|
||||
data={
|
||||
'full_name': 'Other',
|
||||
'email': '[email protected]',
|
||||
'role': 'coach',
|
||||
'is_active_account': 'on',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
assert db.session.get(User, other_id).role == 'coach'
|
||||
@@ -350,11 +375,16 @@ class TestCsrf:
|
||||
"""CSRFProtect is global. This pins that down so a future
|
||||
@csrf.exempt cannot slip in unnoticed."""
|
||||
client = app_with_csrf.test_client()
|
||||
response = client.post('/auth/login', data={
|
||||
'username': 'someone', 'password': 'Password123',
|
||||
})
|
||||
response = client.post(
|
||||
'/auth/login',
|
||||
data={
|
||||
'username': 'someone',
|
||||
'password': 'Password123',
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
class TestCorsPolicy:
|
||||
"""SEC-WEB-003 — with no origins configured, flask-cors defaulted to '*'
|
||||
and, credentials being allowed, echoed back the caller's Origin."""
|
||||
@@ -367,12 +397,19 @@ class TestCorsPolicy:
|
||||
def test_configured_origins_are_still_honoured(self, app_with_csrf):
|
||||
from app.app import create_app
|
||||
|
||||
application = create_app({
|
||||
'SECRET_KEY': 'test', 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
|
||||
'TESTING': True, 'FORCE_HTTPS': False, 'ENABLE_DISCORD_BOT': False,
|
||||
'AUTO_CREATE_TABLES': False, 'CORS_ALLOWED_ORIGINS': 'https://trusted.test',
|
||||
})
|
||||
application = create_app(
|
||||
{
|
||||
'SECRET_KEY': 'test',
|
||||
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
|
||||
'TESTING': True,
|
||||
'FORCE_HTTPS': False,
|
||||
'ENABLE_DISCORD_BOT': False,
|
||||
'AUTO_CREATE_TABLES': False,
|
||||
'CORS_ALLOWED_ORIGINS': 'https://trusted.test',
|
||||
}
|
||||
)
|
||||
response = application.test_client().get(
|
||||
'/auth/login', headers={'Origin': 'https://trusted.test'})
|
||||
'/auth/login', headers={'Origin': 'https://trusted.test'}
|
||||
)
|
||||
|
||||
assert response.headers.get('Access-Control-Allow-Origin') == 'https://trusted.test'
|
||||
|
||||
@@ -37,8 +37,7 @@ class TestUrlParsing:
|
||||
|
||||
def test_the_sqlalchemy_dialect_suffix_is_accepted(self):
|
||||
"""SQLAlchemy writes postgresql+psycopg://, which pg_dump rejects."""
|
||||
conn = parse_database_url(
|
||||
'postgresql+psycopg://u:p@localhost/tryouts')
|
||||
conn = parse_database_url('postgresql+psycopg://u:p@localhost/tryouts')
|
||||
assert conn['dbname'] == 'tryouts'
|
||||
|
||||
def test_the_default_port_is_applied(self):
|
||||
@@ -119,5 +118,4 @@ class TestExitCodes:
|
||||
assert backup_module.main([]) == 1
|
||||
|
||||
def test_verifying_a_missing_archive_fails(self, tmp_path):
|
||||
assert backup_module.main(
|
||||
['--verify-only', str(tmp_path / 'nope.dump')]) == 1
|
||||
assert backup_module.main(['--verify-only', str(tmp_path / 'nope.dump')]) == 1
|
||||
|
||||
+22
-18
@@ -25,7 +25,10 @@ from app.extensions import db
|
||||
def match_factory(app):
|
||||
"""Create a tryout with one player_scrim match and one participant."""
|
||||
from app.models import (
|
||||
Match, MatchParticipant, Tryout, TryoutRegistration,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
)
|
||||
|
||||
def _make(owner_id, player_id, *, description=None, username=None):
|
||||
@@ -39,24 +42,30 @@ def match_factory(app):
|
||||
player.username = username
|
||||
|
||||
tryout = Tryout(
|
||||
title='Spring tryout', game='Valorant', date=date(2030, 5, 1),
|
||||
created_by=owner_id, status='upcoming',
|
||||
title='Spring tryout',
|
||||
game='Valorant',
|
||||
date=date(2030, 5, 1),
|
||||
created_by=owner_id,
|
||||
status='upcoming',
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
|
||||
db.session.add(TryoutRegistration(
|
||||
tryout_id=tryout.id, player_id=player_id))
|
||||
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
|
||||
|
||||
match = Match(
|
||||
tryout_id=tryout.id, title='Scrim A', description=description,
|
||||
date=date(2030, 5, 2), start_time=time(18, 0), end_time=time(19, 0),
|
||||
match_type='player_scrim', created_by=owner_id,
|
||||
tryout_id=tryout.id,
|
||||
title='Scrim A',
|
||||
description=description,
|
||||
date=date(2030, 5, 2),
|
||||
start_time=time(18, 0),
|
||||
end_time=time(19, 0),
|
||||
match_type='player_scrim',
|
||||
created_by=owner_id,
|
||||
)
|
||||
db.session.add(match)
|
||||
db.session.flush()
|
||||
db.session.add(MatchParticipant(
|
||||
match_id=match.id, player_id=player_id))
|
||||
db.session.add(MatchParticipant(match_id=match.id, player_id=player_id))
|
||||
db.session.commit()
|
||||
return tryout.id, match.id
|
||||
|
||||
@@ -68,9 +77,7 @@ def _match_event(payload):
|
||||
|
||||
|
||||
class TestCalendarEventPayload:
|
||||
def test_description_is_returned_verbatim(
|
||||
self, app, client, as_role, make_user, match_factory
|
||||
):
|
||||
def test_description_is_returned_verbatim(self, app, client, as_role, make_user, match_factory):
|
||||
player_id = make_user('player')
|
||||
admin_id = as_role('admin')
|
||||
match_factory(admin_id, player_id, description='Bring your own peripherals')
|
||||
@@ -92,9 +99,7 @@ class TestCalendarEventPayload:
|
||||
assert '<br>' not in props['description']
|
||||
assert props['participants'] not in props['description']
|
||||
|
||||
def test_an_empty_description_stays_empty(
|
||||
self, app, client, as_role, make_user, match_factory
|
||||
):
|
||||
def test_an_empty_description_stays_empty(self, app, client, as_role, make_user, match_factory):
|
||||
player_id = make_user('player')
|
||||
admin_id = as_role('admin')
|
||||
match_factory(admin_id, player_id, description=None)
|
||||
@@ -132,8 +137,7 @@ class TestLegacyHostileData:
|
||||
):
|
||||
player_id = make_user('player')
|
||||
admin_id = as_role('admin')
|
||||
match_factory(admin_id, player_id,
|
||||
description='Normal text', username=self.PAYLOAD)
|
||||
match_factory(admin_id, player_id, description='Normal text', username=self.PAYLOAD)
|
||||
|
||||
props = _match_event(client.get('/matches/api/events').get_json())['extendedProps']
|
||||
|
||||
|
||||
@@ -32,8 +32,10 @@ def contract_for(app, tmp_path):
|
||||
path.write_bytes(PDF_BYTES)
|
||||
with app.app_context():
|
||||
contract = Contract(
|
||||
player_id=player_id, uploaded_by_id=uploader_id,
|
||||
original_filename='contract.pdf', stored_filename=stored,
|
||||
player_id=player_id,
|
||||
uploaded_by_id=uploader_id,
|
||||
original_filename='contract.pdf',
|
||||
stored_filename=stored,
|
||||
file_path=str(path),
|
||||
)
|
||||
db.session.add(contract)
|
||||
@@ -54,7 +56,8 @@ class TestSignedUpload:
|
||||
client.post(
|
||||
f'/users/contracts/{contract_id}/upload_signed',
|
||||
data={'signed_file': (io.BytesIO(NOT_PDF_BYTES), 'signed.pdf')},
|
||||
content_type='multipart/form-data', follow_redirects=True,
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
@@ -63,9 +66,7 @@ class TestSignedUpload:
|
||||
assert contract.signed_file_path is None
|
||||
assert not os.path.exists(directory / 'signed_deadbeef.pdf')
|
||||
|
||||
def test_a_foreign_extension_is_refused(
|
||||
self, app, client, as_role, make_user, contract_for
|
||||
):
|
||||
def test_a_foreign_extension_is_refused(self, app, client, as_role, make_user, contract_for):
|
||||
player_id = as_role('player')
|
||||
admin_id = make_user('admin')
|
||||
contract_id, _directory = contract_for(player_id, admin_id)
|
||||
@@ -73,15 +74,14 @@ class TestSignedUpload:
|
||||
client.post(
|
||||
f'/users/contracts/{contract_id}/upload_signed',
|
||||
data={'signed_file': (io.BytesIO(b'<?php system($_GET[0]); ?>'), 'shell.php')},
|
||||
content_type='multipart/form-data', follow_redirects=True,
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
assert db.session.get(Contract, contract_id).status != 'signed'
|
||||
|
||||
def test_a_real_pdf_still_goes_through(
|
||||
self, app, client, as_role, make_user, contract_for
|
||||
):
|
||||
def test_a_real_pdf_still_goes_through(self, app, client, as_role, make_user, contract_for):
|
||||
"""Guard against over-correcting: signing a contract is the point."""
|
||||
player_id = as_role('player')
|
||||
admin_id = make_user('admin')
|
||||
@@ -90,7 +90,8 @@ class TestSignedUpload:
|
||||
client.post(
|
||||
f'/users/contracts/{contract_id}/upload_signed',
|
||||
data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')},
|
||||
content_type='multipart/form-data', follow_redirects=True,
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
@@ -110,7 +111,8 @@ class TestSignedUpload:
|
||||
client.post(
|
||||
f'/users/contracts/{contract_id}/upload_signed',
|
||||
data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')},
|
||||
content_type='multipart/form-data', follow_redirects=True,
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
|
||||
+3
-4
@@ -21,7 +21,8 @@ from app.app import build_csp
|
||||
|
||||
TEMPLATE_ROOT = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'app', 'templates',
|
||||
'app',
|
||||
'templates',
|
||||
)
|
||||
|
||||
#: Attributes a nonce can never authorise.
|
||||
@@ -126,9 +127,7 @@ class TestInlineHandlerRatchet:
|
||||
continue
|
||||
offenders.append(f'{relative}: {tag}')
|
||||
|
||||
assert not offenders, (
|
||||
'inline <script> without nonce="{{ csp_nonce }}": ' + str(offenders)
|
||||
)
|
||||
assert not offenders, 'inline <script> without nonce="{{ csp_nonce }}": ' + str(offenders)
|
||||
|
||||
def test_the_shared_layout_is_free_of_them(self):
|
||||
"""base.html and macros.html render on every single page."""
|
||||
|
||||
+17
-11
@@ -19,23 +19,29 @@ from app.app import normalise_database_url
|
||||
|
||||
|
||||
class TestNormalisation:
|
||||
@pytest.mark.parametrize('given', [
|
||||
'postgresql://user:pass@host:5432/tryouts',
|
||||
'postgres://user:pass@host:5432/tryouts',
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
'given',
|
||||
[
|
||||
'postgresql://user:pass@host:5432/tryouts',
|
||||
'postgres://user:pass@host:5432/tryouts',
|
||||
],
|
||||
)
|
||||
def test_a_driverless_postgres_url_gets_psycopg(self, given):
|
||||
assert normalise_database_url(given).startswith('postgresql+psycopg://')
|
||||
|
||||
def test_the_rest_of_the_url_is_untouched(self):
|
||||
result = normalise_database_url(
|
||||
'postgresql://u:p%40s[email protected]:5432/tryouts?sslmode=require')
|
||||
assert result == (
|
||||
'postgresql+psycopg://u:p%40s[email protected]:5432/tryouts?sslmode=require')
|
||||
'postgresql://u:p%40s[email protected]:5432/tryouts?sslmode=require'
|
||||
)
|
||||
assert result == ('postgresql+psycopg://u:p%40s[email protected]:5432/tryouts?sslmode=require')
|
||||
|
||||
@pytest.mark.parametrize('given', [
|
||||
'postgresql+psycopg://user:pass@host/db',
|
||||
'postgresql+psycopg2://user:pass@host/db',
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
'given',
|
||||
[
|
||||
'postgresql+psycopg://user:pass@host/db',
|
||||
'postgresql+psycopg2://user:pass@host/db',
|
||||
],
|
||||
)
|
||||
def test_an_explicit_driver_is_left_alone(self, given):
|
||||
"""Naming psycopg2 stays possible for an environment that has it."""
|
||||
assert normalise_database_url(given) == given
|
||||
|
||||
+66
-39
@@ -17,8 +17,17 @@ import pytest
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Match, MatchParticipant, OrgTeam, PersonalNote, Team, TeamMatch,
|
||||
TeamMember, TeamNote, TeamPlayer, Tryout, TryoutRegistration,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
OrgTeam,
|
||||
PersonalNote,
|
||||
Team,
|
||||
TeamMatch,
|
||||
TeamMember,
|
||||
TeamNote,
|
||||
TeamPlayer,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
)
|
||||
|
||||
|
||||
@@ -38,8 +47,13 @@ def world(app, make_user):
|
||||
db.session.add(org_team)
|
||||
db.session.flush()
|
||||
|
||||
tryout = Tryout(title='Spring', game='Valorant', date=date(2030, 4, 1),
|
||||
created_by=admin_id, target_org_team_id=org_team.id)
|
||||
tryout = Tryout(
|
||||
title='Spring',
|
||||
game='Valorant',
|
||||
date=date(2030, 4, 1),
|
||||
created_by=admin_id,
|
||||
target_org_team_id=org_team.id,
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
|
||||
@@ -47,36 +61,57 @@ def world(app, make_user):
|
||||
db.session.add(team)
|
||||
db.session.flush()
|
||||
|
||||
match = Match(tryout_id=tryout.id, title='Scrim', date=date(2030, 4, 2),
|
||||
start_time=time(18, 0), match_type='player_scrim',
|
||||
created_by=admin_id)
|
||||
match = Match(
|
||||
tryout_id=tryout.id,
|
||||
title='Scrim',
|
||||
date=date(2030, 4, 2),
|
||||
start_time=time(18, 0),
|
||||
match_type='player_scrim',
|
||||
created_by=admin_id,
|
||||
)
|
||||
db.session.add(match)
|
||||
db.session.flush()
|
||||
|
||||
db.session.add_all([
|
||||
TryoutRegistration(tryout_id=tryout.id, player_id=player_id),
|
||||
TeamMember(team_id=team.id, player_id=player_id),
|
||||
MatchParticipant(match_id=match.id, player_id=player_id),
|
||||
TeamPlayer(player_id=player_id, org_team_id=org_team.id),
|
||||
TeamNote(org_team_id=org_team.id, coach_id=coach_id, content='Team note'),
|
||||
TeamMatch(org_team_id=org_team.id, title='Season match',
|
||||
date=date(2030, 4, 5), created_by=admin_id),
|
||||
# A note referencing all three contexts at once.
|
||||
PersonalNote(player_id=player_id, coach_id=coach_id,
|
||||
content='Watch the entries',
|
||||
match_id=match.id, team_id=team.id, tryout_id=tryout.id),
|
||||
])
|
||||
db.session.add_all(
|
||||
[
|
||||
TryoutRegistration(tryout_id=tryout.id, player_id=player_id),
|
||||
TeamMember(team_id=team.id, player_id=player_id),
|
||||
MatchParticipant(match_id=match.id, player_id=player_id),
|
||||
TeamPlayer(player_id=player_id, org_team_id=org_team.id),
|
||||
TeamNote(org_team_id=org_team.id, coach_id=coach_id, content='Team note'),
|
||||
TeamMatch(
|
||||
org_team_id=org_team.id,
|
||||
title='Season match',
|
||||
date=date(2030, 4, 5),
|
||||
created_by=admin_id,
|
||||
),
|
||||
# A note referencing all three contexts at once.
|
||||
PersonalNote(
|
||||
player_id=player_id,
|
||||
coach_id=coach_id,
|
||||
content='Watch the entries',
|
||||
match_id=match.id,
|
||||
team_id=team.id,
|
||||
tryout_id=tryout.id,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'admin_id': admin_id, 'coach_id': coach_id, 'player_id': player_id,
|
||||
'org_team_id': org_team.id, 'tryout_id': tryout.id,
|
||||
'team_id': team.id, 'match_id': match.id,
|
||||
'admin_id': admin_id,
|
||||
'coach_id': coach_id,
|
||||
'player_id': player_id,
|
||||
'org_team_id': org_team.id,
|
||||
'tryout_id': tryout.id,
|
||||
'team_id': team.id,
|
||||
'match_id': match.id,
|
||||
}
|
||||
|
||||
|
||||
def _login_admin(client, app, admin_id, login):
|
||||
from app.models import User
|
||||
|
||||
with app.app_context():
|
||||
username = db.session.get(User, admin_id).username
|
||||
login(username)
|
||||
@@ -89,8 +124,7 @@ class TestDeleteMatch:
|
||||
def test_a_populated_match_can_be_deleted(self, app, client, world, login):
|
||||
_login_admin(client, app, world['admin_id'], login)
|
||||
|
||||
response = client.post(f"/matches/{world['match_id']}/delete",
|
||||
follow_redirects=False)
|
||||
response = client.post(f"/matches/{world['match_id']}/delete", follow_redirects=False)
|
||||
|
||||
assert response.status_code < 500, 'deleting a used match raised'
|
||||
with app.app_context():
|
||||
@@ -102,8 +136,7 @@ class TestDeleteMatch:
|
||||
client.post(f"/matches/{world['match_id']}/delete", follow_redirects=True)
|
||||
|
||||
with app.app_context():
|
||||
assert MatchParticipant.query.filter_by(
|
||||
match_id=world['match_id']).count() == 0
|
||||
assert MatchParticipant.query.filter_by(match_id=world['match_id']).count() == 0
|
||||
|
||||
def test_notes_survive_but_lose_their_match_context(self, app, client, world, login):
|
||||
"""A coach's observation keeps its value once the match is gone;
|
||||
@@ -125,8 +158,7 @@ class TestDeleteTryout:
|
||||
def test_a_populated_tryout_can_be_deleted(self, app, client, world, login):
|
||||
_login_admin(client, app, world['admin_id'], login)
|
||||
|
||||
response = client.post(f"/tryouts/{world['tryout_id']}/delete",
|
||||
follow_redirects=False)
|
||||
response = client.post(f"/tryouts/{world['tryout_id']}/delete", follow_redirects=False)
|
||||
|
||||
assert response.status_code < 500
|
||||
with app.app_context():
|
||||
@@ -140,8 +172,7 @@ class TestDeleteTryout:
|
||||
with app.app_context():
|
||||
assert db.session.get(Match, world['match_id']) is None
|
||||
assert db.session.get(Team, world['team_id']) is None
|
||||
assert TryoutRegistration.query.filter_by(
|
||||
tryout_id=world['tryout_id']).count() == 0
|
||||
assert TryoutRegistration.query.filter_by(tryout_id=world['tryout_id']).count() == 0
|
||||
|
||||
def test_notes_survive_the_tryout(self, app, client, world, login):
|
||||
_login_admin(client, app, world['admin_id'], login)
|
||||
@@ -164,8 +195,7 @@ class TestDeleteTeam:
|
||||
def test_a_populated_team_can_be_deleted(self, app, client, world, login):
|
||||
_login_admin(client, app, world['admin_id'], login)
|
||||
|
||||
response = client.post(f"/teams/{world['org_team_id']}/delete",
|
||||
follow_redirects=False)
|
||||
response = client.post(f"/teams/{world['org_team_id']}/delete", follow_redirects=False)
|
||||
|
||||
assert response.status_code < 500
|
||||
with app.app_context():
|
||||
@@ -177,12 +207,9 @@ class TestDeleteTeam:
|
||||
client.post(f"/teams/{world['org_team_id']}/delete", follow_redirects=True)
|
||||
|
||||
with app.app_context():
|
||||
assert TeamNote.query.filter_by(
|
||||
org_team_id=world['org_team_id']).count() == 0
|
||||
assert TeamMatch.query.filter_by(
|
||||
org_team_id=world['org_team_id']).count() == 0
|
||||
assert TeamPlayer.query.filter_by(
|
||||
org_team_id=world['org_team_id']).count() == 0
|
||||
assert TeamNote.query.filter_by(org_team_id=world['org_team_id']).count() == 0
|
||||
assert TeamMatch.query.filter_by(org_team_id=world['org_team_id']).count() == 0
|
||||
assert TeamPlayer.query.filter_by(org_team_id=world['org_team_id']).count() == 0
|
||||
|
||||
def test_the_tryout_survives_and_is_detached(self, app, client, world, login):
|
||||
"""A tryout outlives the team it was aimed at."""
|
||||
|
||||
+18
-16
@@ -24,7 +24,8 @@ def discord_configured(monkeypatch):
|
||||
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', '123456789012345678')
|
||||
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_SECRET', 'not-a-real-secret')
|
||||
monkeypatch.setattr(
|
||||
auth_module, 'DISCORD_REDIRECT_URI',
|
||||
auth_module,
|
||||
'DISCORD_REDIRECT_URI',
|
||||
'https://example.test/auth/discord/callback',
|
||||
)
|
||||
|
||||
@@ -50,16 +51,17 @@ class TestAuthorizationRequest:
|
||||
assert sess[auth_module.DISCORD_STATE_KEY] == sent
|
||||
|
||||
def test_two_requests_get_different_states(self, client, discord_configured):
|
||||
first = _authorize_params(
|
||||
client.get('/auth/discord/login', follow_redirects=False))['state'][0]
|
||||
second = _authorize_params(
|
||||
client.get('/auth/discord/login', follow_redirects=False))['state'][0]
|
||||
first = _authorize_params(client.get('/auth/discord/login', follow_redirects=False))[
|
||||
'state'
|
||||
][0]
|
||||
second = _authorize_params(client.get('/auth/discord/login', follow_redirects=False))[
|
||||
'state'
|
||||
][0]
|
||||
|
||||
assert first != second
|
||||
|
||||
def test_scopes_and_redirect_are_preserved(self, client, discord_configured):
|
||||
params = _authorize_params(
|
||||
client.get('/auth/discord/login', follow_redirects=False))
|
||||
params = _authorize_params(client.get('/auth/discord/login', follow_redirects=False))
|
||||
|
||||
assert params['scope'][0] == 'identify connections'
|
||||
assert params['response_type'][0] == 'code'
|
||||
@@ -87,8 +89,7 @@ class TestCallbackStateValidation:
|
||||
def test_a_callback_without_state_is_rejected(self, client, discord_configured):
|
||||
client.get('/auth/discord/login', follow_redirects=False)
|
||||
|
||||
response = client.get(
|
||||
'/auth/discord/callback?code=attacker-code', follow_redirects=False)
|
||||
response = client.get('/auth/discord/callback?code=attacker-code', follow_redirects=False)
|
||||
|
||||
assert '/auth/register' in response.headers['Location']
|
||||
|
||||
@@ -96,8 +97,8 @@ class TestCallbackStateValidation:
|
||||
client.get('/auth/discord/login', follow_redirects=False)
|
||||
|
||||
response = client.get(
|
||||
'/auth/discord/callback?code=attacker-code&state=forged',
|
||||
follow_redirects=False)
|
||||
'/auth/discord/callback?code=attacker-code&state=forged', follow_redirects=False
|
||||
)
|
||||
|
||||
assert '/auth/register' in response.headers['Location']
|
||||
with client.session_transaction() as sess:
|
||||
@@ -106,18 +107,19 @@ class TestCallbackStateValidation:
|
||||
def test_a_callback_without_a_prior_request_is_rejected(self, client, discord_configured):
|
||||
"""No /discord/login beforehand: nothing to match against."""
|
||||
response = client.get(
|
||||
'/auth/discord/callback?code=x&state=anything', follow_redirects=False)
|
||||
'/auth/discord/callback?code=x&state=anything', follow_redirects=False
|
||||
)
|
||||
|
||||
assert '/auth/register' in response.headers['Location']
|
||||
|
||||
def test_the_state_is_single_use(self, client, discord_configured):
|
||||
"""Consumed on the first callback, valid or not, so it cannot be
|
||||
replayed."""
|
||||
state = _authorize_params(
|
||||
client.get('/auth/discord/login', follow_redirects=False))['state'][0]
|
||||
state = _authorize_params(client.get('/auth/discord/login', follow_redirects=False))[
|
||||
'state'
|
||||
][0]
|
||||
|
||||
client.get(f'/auth/discord/callback?code=x&state={state}',
|
||||
follow_redirects=False)
|
||||
client.get(f'/auth/discord/callback?code=x&state={state}', follow_redirects=False)
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
assert auth_module.DISCORD_STATE_KEY not in sess
|
||||
|
||||
+66
-44
@@ -28,28 +28,27 @@ class TestDefaults:
|
||||
assert 'Se connecter' in body
|
||||
|
||||
def test_the_html_lang_attribute_follows_the_locale(self, client):
|
||||
english = client.get('/auth/login',
|
||||
headers={'Accept-Language': 'en-CA,en;q=0.9'})
|
||||
english = client.get('/auth/login', headers={'Accept-Language': 'en-CA,en;q=0.9'})
|
||||
assert 'lang="en"' in english.get_data(as_text=True)
|
||||
|
||||
|
||||
class TestBrowserNegotiation:
|
||||
def test_an_english_browser_is_served_english(self, client):
|
||||
body = client.get('/auth/login',
|
||||
headers={'Accept-Language': 'en-CA,en;q=0.9'}
|
||||
).get_data(as_text=True)
|
||||
body = client.get('/auth/login', headers={'Accept-Language': 'en-CA,en;q=0.9'}).get_data(
|
||||
as_text=True
|
||||
)
|
||||
assert 'Sign In' in body
|
||||
|
||||
def test_an_unsupported_language_falls_back_to_french(self, client):
|
||||
body = client.get('/auth/login',
|
||||
headers={'Accept-Language': 'de-DE,de;q=0.9'}
|
||||
).get_data(as_text=True)
|
||||
body = client.get('/auth/login', headers={'Accept-Language': 'de-DE,de;q=0.9'}).get_data(
|
||||
as_text=True
|
||||
)
|
||||
assert 'Se connecter' in body
|
||||
|
||||
def test_a_french_browser_is_served_french(self, client):
|
||||
body = client.get('/auth/login',
|
||||
headers={'Accept-Language': 'fr-CA,fr;q=0.9'}
|
||||
).get_data(as_text=True)
|
||||
body = client.get('/auth/login', headers={'Accept-Language': 'fr-CA,fr;q=0.9'}).get_data(
|
||||
as_text=True
|
||||
)
|
||||
assert 'Se connecter' in body
|
||||
|
||||
|
||||
@@ -65,9 +64,9 @@ class TestExplicitSwitch:
|
||||
"""Someone on an English machine who picks French must keep French."""
|
||||
client.get('/lang/fr')
|
||||
|
||||
body = client.get('/auth/login',
|
||||
headers={'Accept-Language': 'en-CA,en;q=0.9'}
|
||||
).get_data(as_text=True)
|
||||
body = client.get('/auth/login', headers={'Accept-Language': 'en-CA,en;q=0.9'}).get_data(
|
||||
as_text=True
|
||||
)
|
||||
|
||||
assert 'Se connecter' in body
|
||||
assert 'lang="fr"' in body
|
||||
@@ -85,16 +84,18 @@ class TestExplicitSwitch:
|
||||
assert 'Sign In' in body, 'an unsupported code must not change the locale'
|
||||
|
||||
def test_the_switcher_redirects_back_to_the_referring_page(self, client):
|
||||
response = client.get('/lang/en',
|
||||
headers={'Referer': 'http://localhost/auth/register'},
|
||||
follow_redirects=False)
|
||||
response = client.get(
|
||||
'/lang/en',
|
||||
headers={'Referer': 'http://localhost/auth/register'},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.headers['Location'].endswith('/auth/register')
|
||||
|
||||
def test_an_external_referer_is_not_followed(self, client):
|
||||
"""An unchecked Referer would make this an open redirect."""
|
||||
response = client.get('/lang/en',
|
||||
headers={'Referer': 'https://evil.test/phishing'},
|
||||
follow_redirects=False)
|
||||
response = client.get(
|
||||
'/lang/en', headers={'Referer': 'https://evil.test/phishing'}, follow_redirects=False
|
||||
)
|
||||
assert 'evil.test' not in response.headers['Location']
|
||||
|
||||
|
||||
@@ -135,13 +136,14 @@ class TestTranslatedContent:
|
||||
body = client.get('/no-such-page').get_data(as_text=True)
|
||||
assert 'Page introuvable' in body
|
||||
|
||||
@pytest.mark.parametrize('locale,expected', [
|
||||
('fr', 'Ce compte a été désactivé.'),
|
||||
('en', 'This account has been deactivated.'),
|
||||
])
|
||||
def test_flash_messages_are_translated(
|
||||
self, app, client, make_user, login, locale, expected
|
||||
):
|
||||
@pytest.mark.parametrize(
|
||||
'locale,expected',
|
||||
[
|
||||
('fr', 'Ce compte a été désactivé.'),
|
||||
('en', 'This account has been deactivated.'),
|
||||
],
|
||||
)
|
||||
def test_flash_messages_are_translated(self, app, client, make_user, login, locale, expected):
|
||||
from app.extensions import db
|
||||
from app.models import User
|
||||
|
||||
@@ -168,11 +170,14 @@ class TestCatalogueIntegrity:
|
||||
|
||||
path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'app', 'translations', locale, 'LC_MESSAGES', 'messages.mo',
|
||||
'app',
|
||||
'translations',
|
||||
locale,
|
||||
'LC_MESSAGES',
|
||||
'messages.mo',
|
||||
)
|
||||
assert os.path.exists(path), (
|
||||
f'{locale} catalogue is not compiled: run '
|
||||
'`pybabel compile -d app/translations`'
|
||||
f'{locale} catalogue is not compiled: run `pybabel compile -d app/translations`'
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize('locale', SUPPORTED_LOCALES)
|
||||
@@ -184,15 +189,18 @@ class TestCatalogueIntegrity:
|
||||
|
||||
path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'app', 'translations', locale, 'LC_MESSAGES', 'messages.po',
|
||||
'app',
|
||||
'translations',
|
||||
locale,
|
||||
'LC_MESSAGES',
|
||||
'messages.po',
|
||||
)
|
||||
with io.open(path, encoding='utf-8') as handle:
|
||||
catalog = read_po(handle, locale=locale)
|
||||
|
||||
untranslated = [m.id for m in catalog if m.id and not m.string]
|
||||
assert not untranslated, (
|
||||
f'{len(untranslated)} untranslated string(s) in {locale}: '
|
||||
f'{untranslated[:5]}'
|
||||
f'{len(untranslated)} untranslated string(s) in {locale}: {untranslated[:5]}'
|
||||
)
|
||||
|
||||
|
||||
@@ -233,9 +241,9 @@ class TestLocaleSurvivesSessionRotation:
|
||||
client.get('/lang/fr')
|
||||
as_role('player')
|
||||
|
||||
body = client.get('/users/profile',
|
||||
headers={'Accept-Language': 'en-CA,en;q=0.9'}
|
||||
).get_data(as_text=True)
|
||||
body = client.get('/users/profile', headers={'Accept-Language': 'en-CA,en;q=0.9'}).get_data(
|
||||
as_text=True
|
||||
)
|
||||
assert 'lang="fr"' in body
|
||||
|
||||
def test_the_csrf_token_is_still_preserved(self, app, client, make_user, login):
|
||||
@@ -273,20 +281,34 @@ class TestFlashMessagesAreTranslated:
|
||||
built at import time, before any request exists."""
|
||||
as_role('admin')
|
||||
|
||||
body = client.post('/users/create', data={
|
||||
'username': 'x', 'email': 'not-an-email',
|
||||
'password': 'a', 'full_name': 'X', 'role': 'coach',
|
||||
}, follow_redirects=True).get_data(as_text=True)
|
||||
body = client.post(
|
||||
'/users/create',
|
||||
data={
|
||||
'username': 'x',
|
||||
'email': 'not-an-email',
|
||||
'password': 'a',
|
||||
'full_name': 'X',
|
||||
'role': 'coach',
|
||||
},
|
||||
follow_redirects=True,
|
||||
).get_data(as_text=True)
|
||||
|
||||
assert 'Le nom d' in body and 'utilisateur doit compter' in body
|
||||
|
||||
def test_a_message_with_a_value_keeps_it(self, client, as_role):
|
||||
as_role('admin')
|
||||
|
||||
body = client.post('/users/create', data={
|
||||
'username': 'recrue', 'email': '[email protected]',
|
||||
'password': 'Password123', 'full_name': 'Nouvelle Recrue', 'role': 'coach',
|
||||
}, follow_redirects=True).get_data(as_text=True)
|
||||
body = client.post(
|
||||
'/users/create',
|
||||
data={
|
||||
'username': 'recrue',
|
||||
'email': '[email protected]',
|
||||
'password': 'Password123',
|
||||
'full_name': 'Nouvelle Recrue',
|
||||
'role': 'coach',
|
||||
},
|
||||
follow_redirects=True,
|
||||
).get_data(as_text=True)
|
||||
|
||||
assert 'Nouvelle Recrue' in body
|
||||
assert 'créé avec le rôle coach' in body
|
||||
|
||||
+86
-59
@@ -19,11 +19,19 @@ import pytest
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Contract, OrgTeam, PersonalNote, TeamPlayer, Tryout, TryoutRegistration,
|
||||
Contract,
|
||||
OrgTeam,
|
||||
PersonalNote,
|
||||
TeamPlayer,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
)
|
||||
from app.permissions import (
|
||||
can_manage_player_contract, coach_can_access_player, coach_org_team_ids,
|
||||
coach_player_ids, visible_org_teams,
|
||||
can_manage_player_contract,
|
||||
coach_can_access_player,
|
||||
coach_org_team_ids,
|
||||
coach_player_ids,
|
||||
visible_org_teams,
|
||||
)
|
||||
|
||||
|
||||
@@ -33,8 +41,9 @@ def team_factory(app):
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
team = OrgTeam(name=name, created_by=coach_id or legacy_coach_id,
|
||||
coach_id=legacy_coach_id)
|
||||
team = OrgTeam(
|
||||
name=name, created_by=coach_id or legacy_coach_id, coach_id=legacy_coach_id
|
||||
)
|
||||
db.session.add(team)
|
||||
db.session.flush()
|
||||
if coach_id:
|
||||
@@ -48,25 +57,23 @@ def team_factory(app):
|
||||
|
||||
|
||||
class TestTeamResolution:
|
||||
def test_a_coach_attached_by_the_relationship_is_found(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_a_coach_attached_by_the_relationship_is_found(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
team_id = team_factory('Varsity', coach_id=coach_id)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert coach_org_team_ids(coach) == [team_id]
|
||||
|
||||
def test_a_coach_attached_by_the_legacy_column_is_found(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_a_coach_attached_by_the_legacy_column_is_found(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
team_id = team_factory('JV', legacy_coach_id=coach_id)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert coach_org_team_ids(coach) == [team_id]
|
||||
|
||||
@@ -74,6 +81,7 @@ class TestTeamResolution:
|
||||
coach_id = make_user('coach')
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
assert coach_org_team_ids(db.session.get(User, coach_id)) == []
|
||||
|
||||
|
||||
@@ -85,47 +93,45 @@ class TestPlayerAccess:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
assert coach_can_access_player(db.session.get(User, coach_id), player_id)
|
||||
|
||||
def test_a_second_coach_of_the_team_also_reaches_the_player(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_a_second_coach_of_the_team_also_reaches_the_player(self, app, make_user, team_factory):
|
||||
"""The case that used to fail everywhere users.py looked at coach_id."""
|
||||
first_coach = make_user('coach')
|
||||
second_coach = make_user('coach')
|
||||
player_id = make_user('player')
|
||||
team_id = team_factory('Varsity', legacy_coach_id=first_coach,
|
||||
player_ids=[player_id])
|
||||
team_id = team_factory('Varsity', legacy_coach_id=first_coach, player_ids=[player_id])
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
team = db.session.get(OrgTeam, team_id)
|
||||
team.coaches.append(db.session.get(User, second_coach))
|
||||
db.session.commit()
|
||||
|
||||
assert coach_can_access_player(db.session.get(User, second_coach), player_id)
|
||||
|
||||
def test_a_coach_does_not_reach_an_unrelated_player(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_a_coach_does_not_reach_an_unrelated_player(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
stranger_id = make_user('player')
|
||||
team_factory('Varsity', coach_id=coach_id)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
assert not coach_can_access_player(db.session.get(User, coach_id), stranger_id)
|
||||
|
||||
def test_a_coach_reaches_a_player_registered_in_their_tryout(
|
||||
self, app, make_user
|
||||
):
|
||||
def test_a_coach_reaches_a_player_registered_in_their_tryout(self, app, make_user):
|
||||
coach_id = make_user('coach')
|
||||
player_id = make_user('player')
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
tryout = Tryout(title='Open tryout', game='Valorant',
|
||||
date=date(2030, 3, 1), created_by=coach_id)
|
||||
|
||||
tryout = Tryout(
|
||||
title='Open tryout', game='Valorant', date=date(2030, 3, 1), created_by=coach_id
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
tryout.coaches.append(db.session.get(User, coach_id))
|
||||
@@ -138,6 +144,7 @@ class TestPlayerAccess:
|
||||
coach_id = make_user('coach')
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
assert not coach_can_access_player(db.session.get(User, coach_id), None)
|
||||
|
||||
|
||||
@@ -152,9 +159,14 @@ class TestPersonalNoteRoutes:
|
||||
coach_id = as_role('coach')
|
||||
team_factory('Varsity', coach_id=coach_id)
|
||||
|
||||
client.post('/users/personal-notes/manage', data={
|
||||
'player_id': stranger_id, 'content': 'Unrelated observation',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
'/users/personal-notes/manage',
|
||||
data={
|
||||
'player_id': stranger_id,
|
||||
'content': 'Unrelated observation',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
assert PersonalNote.query.filter_by(player_id=stranger_id).count() == 0
|
||||
@@ -167,9 +179,14 @@ class TestPersonalNoteRoutes:
|
||||
coach_id = as_role('coach')
|
||||
team_factory('Varsity', coach_id=coach_id, player_ids=[player_id])
|
||||
|
||||
client.post('/users/personal-notes/manage', data={
|
||||
'player_id': player_id, 'content': 'Good positioning today',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
'/users/personal-notes/manage',
|
||||
data={
|
||||
'player_id': player_id,
|
||||
'content': 'Good positioning today',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
note = PersonalNote.query.filter_by(player_id=player_id).one()
|
||||
@@ -184,17 +201,18 @@ class TestContractVisibility:
|
||||
def _contract(app, player_id, uploader_id, team_id=None):
|
||||
with app.app_context():
|
||||
contract = Contract(
|
||||
player_id=player_id, team_id=team_id, uploaded_by_id=uploader_id,
|
||||
original_filename='c.pdf', stored_filename='uuid.pdf',
|
||||
player_id=player_id,
|
||||
team_id=team_id,
|
||||
uploaded_by_id=uploader_id,
|
||||
original_filename='c.pdf',
|
||||
stored_filename='uuid.pdf',
|
||||
file_path='/tmp/uuid.pdf',
|
||||
)
|
||||
db.session.add(contract)
|
||||
db.session.commit()
|
||||
return contract.id
|
||||
|
||||
def test_a_teamless_contract_is_not_visible_to_every_coach(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_a_teamless_contract_is_not_visible_to_every_coach(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
stranger_id = make_user('player')
|
||||
admin_id = make_user('admin')
|
||||
@@ -203,12 +221,11 @@ class TestContractVisibility:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
contract = db.session.get(Contract, contract_id)
|
||||
assert not contract.can_view(db.session.get(User, coach_id))
|
||||
|
||||
def test_a_coach_sees_the_contract_of_their_own_player(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_a_coach_sees_the_contract_of_their_own_player(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
player_id = make_user('player')
|
||||
admin_id = make_user('admin')
|
||||
@@ -217,6 +234,7 @@ class TestContractVisibility:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
contract = db.session.get(Contract, contract_id)
|
||||
assert contract.can_view(db.session.get(User, coach_id))
|
||||
|
||||
@@ -227,6 +245,7 @@ class TestContractVisibility:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
contract = db.session.get(Contract, contract_id)
|
||||
assert contract.can_view(db.session.get(User, player_id))
|
||||
|
||||
@@ -237,6 +256,7 @@ class TestContractVisibility:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
contract = db.session.get(Contract, contract_id)
|
||||
assert contract.can_view(db.session.get(User, admin_id))
|
||||
|
||||
@@ -251,9 +271,7 @@ class TestSecondTeam:
|
||||
attached by the relationship only reached none of it. Both defects were
|
||||
live, and silent: the pages rendered, just empty."""
|
||||
|
||||
def test_a_coach_of_two_teams_reaches_both_squads(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_a_coach_of_two_teams_reaches_both_squads(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
first_player = make_user('player')
|
||||
second_player = make_user('player')
|
||||
@@ -262,6 +280,7 @@ class TestSecondTeam:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert sorted(coach_player_ids(coach)) == sorted([first_player, second_player])
|
||||
|
||||
@@ -275,6 +294,7 @@ class TestSecondTeam:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert can_manage_player_contract(coach, second_player)
|
||||
|
||||
@@ -287,6 +307,7 @@ class TestSecondTeam:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert not can_manage_player_contract(coach, stranger_id)
|
||||
|
||||
@@ -326,6 +347,7 @@ class TestTeamVisibility:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
teams = visible_org_teams(db.session.get(User, coach_id))
|
||||
assert [t.name for t in teams] == ['Varsity']
|
||||
|
||||
@@ -337,6 +359,7 @@ class TestTeamVisibility:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
teams = visible_org_teams(db.session.get(User, admin_id))
|
||||
assert [t.name for t in teams] == ['JV', 'Varsity']
|
||||
|
||||
@@ -347,11 +370,10 @@ class TestTeamVisibility:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
assert visible_org_teams(db.session.get(User, scout_id)) == []
|
||||
|
||||
def test_the_team_listing_shows_the_relationship_team(
|
||||
self, app, client, as_role, team_factory
|
||||
):
|
||||
def test_the_team_listing_shows_the_relationship_team(self, app, client, as_role, team_factory):
|
||||
coach_id = as_role('coach')
|
||||
team_factory('Northern Lights', coach_id=coach_id)
|
||||
|
||||
@@ -366,49 +388,54 @@ class TestTryoutVisibility:
|
||||
@staticmethod
|
||||
def _tryout(app, *, creator_id, title, target_team_id=None, legacy_coach_id=None):
|
||||
with app.app_context():
|
||||
tryout = Tryout(title=title, game='Valorant', date=date(2030, 5, 1),
|
||||
created_by=creator_id, target_org_team_id=target_team_id,
|
||||
coach_id=legacy_coach_id)
|
||||
tryout = Tryout(
|
||||
title=title,
|
||||
game='Valorant',
|
||||
date=date(2030, 5, 1),
|
||||
created_by=creator_id,
|
||||
target_org_team_id=target_team_id,
|
||||
coach_id=legacy_coach_id,
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.commit()
|
||||
return tryout.id
|
||||
|
||||
def test_a_tryout_targeting_a_legacy_team_is_visible(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_a_tryout_targeting_a_legacy_team_is_visible(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
team_id = team_factory('Varsity', legacy_coach_id=coach_id)
|
||||
self._tryout(app, creator_id=coach_id, title='Spring intake',
|
||||
target_team_id=team_id)
|
||||
self._tryout(app, creator_id=coach_id, title='Spring intake', target_team_id=team_id)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert [t.title for t in coach.get_visible_tryouts()] == ['Spring intake']
|
||||
|
||||
def test_the_same_tryout_is_manageable(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
team_id = team_factory('Varsity', legacy_coach_id=coach_id)
|
||||
tryout_id = self._tryout(app, creator_id=coach_id, title='Spring intake',
|
||||
target_team_id=team_id)
|
||||
tryout_id = self._tryout(
|
||||
app, creator_id=coach_id, title='Spring intake', target_team_id=team_id
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert coach.can_manage_this_tryout(db.session.get(Tryout, tryout_id))
|
||||
|
||||
def test_another_coachs_tryout_stays_out_of_reach(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_another_coachs_tryout_stays_out_of_reach(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
other_id = make_user('coach')
|
||||
team_factory('Varsity', coach_id=coach_id)
|
||||
other_team = team_factory('JV', coach_id=other_id)
|
||||
tryout_id = self._tryout(app, creator_id=other_id, title='Their intake',
|
||||
target_team_id=other_team)
|
||||
tryout_id = self._tryout(
|
||||
app, creator_id=other_id, title='Their intake', target_team_id=other_team
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert coach.get_visible_tryouts() == []
|
||||
assert not coach.can_manage_this_tryout(db.session.get(Tryout, tryout_id))
|
||||
|
||||
@@ -44,9 +44,7 @@ class TestPolymorphicIdentity:
|
||||
with app.app_context():
|
||||
assert isinstance(db.session.get(User, target_id), Coach)
|
||||
|
||||
def test_the_new_role_grants_its_pages_right_away(
|
||||
self, app, client, as_role, make_user, login
|
||||
):
|
||||
def test_the_new_role_grants_its_pages_right_away(self, app, client, as_role, make_user, login):
|
||||
"""isinstance() is how this application authorises; a stale class in
|
||||
the identity map is a stale permission set."""
|
||||
target_id = make_user('player')
|
||||
@@ -61,9 +59,7 @@ class TestPolymorphicIdentity:
|
||||
# Coach-only, and it is a coach's own page rather than a redirect.
|
||||
assert client.get('/users/notes-dashboard').status_code == 200
|
||||
|
||||
def test_the_other_fields_of_the_edit_land_too(
|
||||
self, app, client, as_role, make_user
|
||||
):
|
||||
def test_the_other_fields_of_the_edit_land_too(self, app, client, as_role, make_user):
|
||||
target_id = make_user('player')
|
||||
as_role('admin')
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""HTTP hardening, error disclosure, and template escaping."""
|
||||
|
||||
|
||||
from app.app import nl2br
|
||||
|
||||
|
||||
@@ -35,8 +34,7 @@ class TestErrorDisclosure:
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise RuntimeError(
|
||||
'FATAL: password authentication failed for user "app" '
|
||||
'host=db.internal port=5432'
|
||||
'FATAL: password authentication failed for user "app" host=db.internal port=5432'
|
||||
)
|
||||
|
||||
original = db.session.execute
|
||||
|
||||
Reference in New Issue
Block a user