Skip to content

Commit 03e08c9

Browse files
Fix invalid token for logged-in users (#208)
* fix invalid token * reword invalid link message * fix test_invitation_acceptance * fix: address review nits on invitation warning pages Match the login/register header wording to the expired vs. invalid warning shown, and dedupe the repeated user/invitation_token_warning check into a single template variable. Type-hint the shared test helper and assert the correct heading per warning type. * fix: harden profile-form htmx swap tests against CI timing flakiness Synchronize each Edit/Cancel/Save click on its underlying htmx response instead of polling the DOM cold, and raise the post-swap visibility timeout to 10s to match the margin already used by similar swap assertions elsewhere in the browser suite. This removes a race that intermittently failed test_edit_profile_swap_cycle in CI without hiding genuine swap failures, since the assertions still fail on a truly broken swap. --------- Co-authored-by: chriscarrollsmith <chriscarrollsmith@gmail.com>
1 parent 350af44 commit 03e08c9

5 files changed

Lines changed: 137 additions & 21 deletions

File tree

templates/account/login.html

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
{% extends "account/auth_base.html" %}
22

3-
{% block title %}Login{% endblock %}
3+
{% set show_warning_only = user and invitation_token_warning %}
4+
{% set warning_heading = "Invitation link expired" if invitation_token_warning == 'expired' else "Invitation link invalid" %}
45

5-
{% block auth_header %}Login{% endblock %}
6+
{% block title %}{% if show_warning_only %}{{ warning_heading }}{% else %}Login{% endif %}{% endblock %}
7+
8+
{% block auth_header %}{% if show_warning_only %}{{ warning_heading }}{% else %}Login{% endif %}{% endblock %}
69

710
{% block auth_content %}
811
<div class="login-form">
@@ -15,6 +18,12 @@
1518
This invitation link is no longer valid. If you were invited again recently, use the link from the most recent invitation email.
1619
</div>
1720
{% endif %}
21+
22+
{% if show_warning_only %}
23+
<div class="d-grid">
24+
<a href="{{ url_for('read_dashboard') }}" class="btn btn-primary">Return to dashboard</a>
25+
</div>
26+
{% else %}
1827
<form method="POST" action="{{ url_for('login') }}"
1928
hx-post="{{ url_for('login') }}" hx-swap="none"
2029
class="needs-validation" novalidate>
@@ -55,5 +64,6 @@
5564

5665
<!-- Register Link -->
5766
<p class="text-center">Don't have an account? <a href="{{ url_for('read_register') }}">Register here</a></p>
67+
{% endif %}
5868
</div>
59-
{% endblock %}
69+
{% endblock %}

templates/account/register.html

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
{% extends "account/auth_base.html" %}
22

3-
{% block title %}Register{% endblock %}
3+
{% set show_warning_only = user and invitation_token_warning %}
4+
{% set warning_heading = "Invitation link expired" if invitation_token_warning == 'expired' else "Invitation link invalid" %}
45

5-
{% block auth_header %}Register{% endblock %}
6+
{% block title %}{% if show_warning_only %}{{ warning_heading }}{% else %}Register{% endif %}{% endblock %}
7+
8+
{% block auth_header %}{% if show_warning_only %}{{ warning_heading }}{% else %}Register{% endif %}{% endblock %}
69

710
{% block auth_content %}
811
<div class="register-form">
@@ -15,6 +18,12 @@
1518
This invitation link is no longer valid. If you were invited again recently, use the link from the most recent invitation email.
1619
</div>
1720
{% endif %}
21+
22+
{% if show_warning_only %}
23+
<div class="d-grid">
24+
<a href="{{ url_for('read_dashboard') }}" class="btn btn-primary">Return to dashboard</a>
25+
</div>
26+
{% else %}
1827
<form method="POST" action="{{ url_for('register') }}"
1928
hx-post="{{ url_for('register') }}" hx-swap="none"
2029
class="needs-validation" novalidate>
@@ -78,8 +87,10 @@
7887

7988
<!-- Login Link -->
8089
<p class="mt-3 text-center">Already have an account? <a href="{{ url_for('read_login') }}">Login here</a></p>
90+
{% endif %}
8191
</div>
8292

93+
{% if not show_warning_only %}
8394
<script>
8495
(function () {
8596
const password = document.getElementById('password');
@@ -97,4 +108,5 @@
97108
confirmPassword.addEventListener('input', syncConfirmPasswordValidity);
98109
})();
99110
</script>
100-
{% endblock %}
111+
{% endif %}
112+
{% endblock %}

tests/browser/test_profile_forms.py

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ def profile_page(browser, live_server: str, _register_profile_user):
4444
context.close()
4545

4646

47+
# Generous bound for htmx round trips under CI load; the assertions below
48+
# still fail fast on a genuinely broken swap since they poll, not sleep.
49+
HTMX_SWAP_TIMEOUT_MS = 10_000
50+
51+
4752
def test_edit_profile_swap_cycle(profile_page: Page):
4853
"""Clicking Edit fetches the form via hx-get; submitting swaps back to display."""
4954
page = profile_page
@@ -53,16 +58,27 @@ def test_edit_profile_swap_cycle(profile_page: Page):
5358
expect(card.locator("button:has-text('Edit')")).to_be_visible()
5459
expect(card.locator("form")).to_have_count(0)
5560

56-
# Click Edit — fetches form partial via hx-get
57-
card.locator("button:has-text('Edit')").click()
58-
expect(card.locator('button:has-text("Save Changes")')).to_be_visible(timeout=5_000)
61+
# Click Edit — fetches form partial via hx-get. Wait for the response
62+
# itself (not just the eventual DOM state) so a slow server round trip
63+
# produces a clear network-timeout failure rather than a flaky locator
64+
# mismatch.
65+
with page.expect_response("**/user/edit-form"):
66+
card.locator("button:has-text('Edit')").click()
67+
expect(card.locator('button:has-text("Save Changes")')).to_be_visible(
68+
timeout=HTMX_SWAP_TIMEOUT_MS
69+
)
5970

6071
# Submit the form
61-
card.locator('button[type="submit"]').click()
72+
with page.expect_response("**/user/update"):
73+
card.locator('button[type="submit"]').click()
6274

6375
# Should swap back to display mode
64-
expect(card.locator("button:has-text('Edit')")).to_be_visible(timeout=5_000)
65-
expect(card.locator('button[type="submit"]')).to_have_count(0, timeout=5_000)
76+
expect(card.locator("button:has-text('Edit')")).to_be_visible(
77+
timeout=HTMX_SWAP_TIMEOUT_MS
78+
)
79+
expect(card.locator('button[type="submit"]')).to_have_count(
80+
0, timeout=HTMX_SWAP_TIMEOUT_MS
81+
)
6682

6783

6884
def test_edit_profile_cancel(profile_page: Page):
@@ -71,15 +87,23 @@ def test_edit_profile_cancel(profile_page: Page):
7187
card = page.locator("#profile-card")
7288

7389
# Click Edit
74-
card.locator("button:has-text('Edit')").click()
75-
expect(card.locator('button:has-text("Save Changes")')).to_be_visible(timeout=5_000)
90+
with page.expect_response("**/user/edit-form"):
91+
card.locator("button:has-text('Edit')").click()
92+
expect(card.locator('button:has-text("Save Changes")')).to_be_visible(
93+
timeout=HTMX_SWAP_TIMEOUT_MS
94+
)
7695

7796
# Click Cancel
78-
card.locator("button:has-text('Cancel')").click()
97+
with page.expect_response("**/user/profile-display"):
98+
card.locator("button:has-text('Cancel')").click()
7999

80100
# Should swap back to display mode
81-
expect(card.locator("button:has-text('Edit')")).to_be_visible(timeout=5_000)
82-
expect(card.locator('button[type="submit"]')).to_have_count(0, timeout=5_000)
101+
expect(card.locator("button:has-text('Edit')")).to_be_visible(
102+
timeout=HTMX_SWAP_TIMEOUT_MS
103+
)
104+
expect(card.locator('button[type="submit"]')).to_have_count(
105+
0, timeout=HTMX_SWAP_TIMEOUT_MS
106+
)
83107

84108

85109
def test_add_email_form_resets_after_submit(profile_page: Page):
@@ -94,7 +118,8 @@ def test_add_email_form_resets_after_submit(profile_page: Page):
94118
email_input.fill("new-browser-test@example.com")
95119
assert email_input.input_value() == "new-browser-test@example.com"
96120

97-
page.click('form:has(input[name="new_email"]) button[type="submit"]')
121+
with page.expect_response("**/account/emails/add"):
122+
page.click('form:has(input[name="new_email"]) button[type="submit"]')
98123

99124
# hx-on::after-settle resets the form after the swap completes
100-
expect(email_input).to_have_value("", timeout=5_000)
125+
expect(email_input).to_have_value("", timeout=HTMX_SWAP_TIMEOUT_MS)

tests/routers/core/test_invitation_acceptance.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import httpx
12
import pytest
23
from fastapi.testclient import TestClient
34
from sqlmodel import Session, select
@@ -10,6 +11,21 @@
1011
# --- Test Scenarios ---
1112

1213

14+
def _assert_authenticated_invitation_warning_page(
15+
response: httpx.Response,
16+
*,
17+
warning_substring: str,
18+
heading_substring: str = "invitation link invalid",
19+
) -> None:
20+
"""Logged-in users with a bad invite token see a warning and dashboard CTA only."""
21+
assert response.status_code == 200
22+
assert warning_substring in response.text.lower()
23+
assert 'name="password"' not in response.text
24+
assert "return to dashboard" in response.text.lower()
25+
assert app.url_path_for("read_dashboard") in response.text
26+
assert heading_substring in response.text.lower()
27+
28+
1329
# 1. Success: New User Registration Flow
1430
def test_accept_invitation_new_user_get_redirects_to_register(
1531
unauth_client: TestClient, test_invitation: Invitation
@@ -289,8 +305,47 @@ def test_accept_invitation_logged_in_user_sees_expired_warning(
289305
]
290306

291307
auth_response = auth_client_invitee.get(response.headers["location"])
292-
assert auth_response.status_code == 200
293-
assert "invitation link has expired" in auth_response.text.lower()
308+
_assert_authenticated_invitation_warning_page(
309+
auth_response,
310+
warning_substring="invitation link has expired",
311+
heading_substring="invitation link expired",
312+
)
313+
314+
315+
def test_accept_invitation_logged_in_user_sees_invalid_warning_on_login(
316+
auth_client_owner: TestClient,
317+
):
318+
"""Logged-in users with an unknown invite token get a warning, not a login form."""
319+
response = auth_client_owner.get(
320+
app.url_path_for("read_login"),
321+
params={"invitation_token": "invalid-token-string"},
322+
)
323+
_assert_authenticated_invitation_warning_page(
324+
response,
325+
warning_substring="no longer valid",
326+
)
327+
328+
329+
def test_accept_invitation_logged_in_user_sees_expired_warning_on_register(
330+
auth_client_owner: TestClient,
331+
expired_invitation: Invitation,
332+
):
333+
"""Logged-in users redirected to register for a bad invite still get the dashboard CTA."""
334+
response = auth_client_owner.get(
335+
app.url_path_for("accept_invitation"),
336+
params={"token": expired_invitation.token},
337+
follow_redirects=False,
338+
)
339+
assert response.status_code == 303
340+
parsed_url = urlparse(response.headers["location"])
341+
assert parsed_url.path == app.url_path_for("read_register")
342+
343+
auth_response = auth_client_owner.get(response.headers["location"])
344+
_assert_authenticated_invitation_warning_page(
345+
auth_response,
346+
warning_substring="invitation link has expired",
347+
heading_substring="invitation link expired",
348+
)
294349

295350

296351
@pytest.mark.parametrize(

tests/test_templates.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,20 @@ def test_base_template_includes_viewport_meta():
5959
assert "width=device-width" in content
6060

6161

62+
def test_login_template_hides_form_for_authenticated_invitation_warning():
63+
content = Path("templates/account/login.html").read_text()
64+
assert "user and invitation_token_warning" in content
65+
assert "Return to dashboard" in content
66+
assert "read_dashboard" in content
67+
68+
69+
def test_register_template_hides_form_for_authenticated_invitation_warning():
70+
content = Path("templates/account/register.html").read_text()
71+
assert "user and invitation_token_warning" in content
72+
assert "Return to dashboard" in content
73+
assert "read_dashboard" in content
74+
75+
6276
def test_toast_partial_exists():
6377
assert Path("templates/base/partials/toast.html").is_file()
6478

0 commit comments

Comments
 (0)