Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -152,49 +152,49 @@
"filename": "account/tests.py",
"hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f",
"is_verified": false,
"line_number": 74
"line_number": 75
},
{
"type": "Secret Keyword",
"filename": "account/tests.py",
"hashed_secret": "1816851d10187b57a93235b52495e615629f906d",
"is_verified": false,
"line_number": 103
"line_number": 104
},
{
"type": "Secret Keyword",
"filename": "account/tests.py",
"hashed_secret": "0fa86f7cd4925d1bc1f299fefdaeb3cede77592f",
"is_verified": false,
"line_number": 146
"line_number": 147
},
{
"type": "Secret Keyword",
"filename": "account/tests.py",
"hashed_secret": "d8ecf7db8fc9ec9c31bc5c9ae2929cc599c75f8d",
"is_verified": false,
"line_number": 176
"line_number": 177
},
{
"type": "Secret Keyword",
"filename": "account/tests.py",
"hashed_secret": "0d8b28805975effded2c628b96d75cd3b47bbdcf",
"is_verified": false,
"line_number": 257
"line_number": 258
},
{
"type": "Secret Keyword",
"filename": "account/tests.py",
"hashed_secret": "1151a8c33d6ae2daf21ad0d466488e707c1c7f8d",
"is_verified": false,
"line_number": 281
"line_number": 282
},
{
"type": "Secret Keyword",
"filename": "account/tests.py",
"hashed_secret": "bdb8465ce041d94a0e490564f2162dcc87d4a46a",
"is_verified": false,
"line_number": 289
"line_number": 290
}
],
"blog/tests.py": [
Expand Down Expand Up @@ -225,5 +225,5 @@
}
]
},
"generated_at": "2026-05-14T23:21:05Z"
"generated_at": "2026-05-16T23:54:17Z"
}
11 changes: 11 additions & 0 deletions account/templates/account/partials/login_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,21 @@
{% for field in login_form %}
<div>
<label class="block text-sm font-medium text-on-surface mb-1">{{ field.label }}</label>
{% if field.field.widget.input_type == 'password' %}
<div class="relative" data-pw-wrapper>
<input type="password"
name="{{ field.name }}"
placeholder="{{ field.label }}"
class="w-full bg-surface-container-lowest border border-outline-variant/20 rounded-lg py-3 px-4 pr-12 text-sm focus:ring-2 focus:ring-secondary/20 focus:border-secondary"/>
{% include 'snippets/password_toggle.html' %}
</div>
{% else %}
<input type="{{ field.field.widget.input_type|default:'text' }}"
name="{{ field.name }}"
value="{{ field.value|default:'' }}"
placeholder="{{ field.label }}"
class="w-full bg-surface-container-lowest border border-outline-variant/20 rounded-lg py-3 px-4 text-sm focus:ring-2 focus:ring-secondary/20 focus:border-secondary"/>
{% endif %}
{% for error in field.errors %}
<p class="text-xs text-error mt-1">{{ error }}</p>
{% endfor %}
Expand All @@ -37,3 +47,4 @@

<button type="submit" class="w-full bg-primary-container text-on-primary py-3 rounded-lg font-semibold hover:opacity-90 transition motion-safe:active:scale-[0.98]">Sign In</button>
</form>
{% include 'snippets/password_toggle_js.html' %}
11 changes: 11 additions & 0 deletions account/templates/account/partials/register_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,21 @@
{% for field in registration_form %}
<div>
<label class="block text-sm font-medium text-on-surface mb-1">{{ field.label }}</label>
{% if field.field.widget.input_type == 'password' %}
<div class="relative" data-pw-wrapper>
<input type="password"
name="{{ field.name }}"
placeholder="{{ field.label }}"
class="w-full bg-surface-container-lowest border border-outline-variant/20 rounded-lg py-3 px-4 pr-12 text-sm focus:ring-2 focus:ring-secondary/20 focus:border-secondary"/>
{% include 'snippets/password_toggle.html' %}
</div>
{% else %}
<input type="{{ field.field.widget.input_type|default:'text' }}"
name="{{ field.name }}"
value="{{ field.value|default:'' }}"
placeholder="{{ field.label }}"
class="w-full bg-surface-container-lowest border border-outline-variant/20 rounded-lg py-3 px-4 text-sm focus:ring-2 focus:ring-secondary/20 focus:border-secondary"/>
{% endif %}
{% for error in field.errors %}
<p class="text-xs text-error mt-1">{{ error }}</p>
{% endfor %}
Expand All @@ -28,3 +38,4 @@

<p class="text-xs text-on-surface-variant text-center">By creating an account, you agree to our Terms and Privacy Policy</p>
</form>
{% include 'snippets/password_toggle_js.html' %}
78 changes: 78 additions & 0 deletions account/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from unittest.mock import patch

from django.conf import settings
from django.contrib.auth.tokens import default_token_generator
from django.core import mail
from django.core.cache import cache
from django.core.mail import get_connection
Expand Down Expand Up @@ -1219,3 +1220,80 @@ def test_verification_email_from_address_is_a_nyasablog_address(self):
send_verification_email(self.user)
sent = mail.outbox[0]
self.assertIn('@nyasablog.com', sent.from_email)


class PasswordToggleTests(TestCase):
def setUp(self):
self.user = Account.objects.create_user(
email='toggle@nyasablog.com', username='toggleuser',
password='Str0ngPass!9', # pragma: allowlist secret
)
self.user.email_verified = True
self.user.save()

def test_login_page_has_exactly_one_password_toggle(self):
resp = self.client.get(reverse('login'))
self.assertEqual(resp.status_code, 200)
body = resp.content.decode()
# Count the rendered toggle button by its aria-label attribute.
# NOT `data-pw-toggle`: that literal also appears inside the
# delegated script's `closest('[data-pw-toggle]')` selector, so
# counting it would double-count. The JS sets the label via
# setAttribute with bare 'Show password' (no `aria-label="`
# prefix), so this token only matches rendered markup.
self.assertEqual(body.count('aria-label="Show password"'), 1)
self.assertIn('data-pw-toggle', body)
self.assertIn('type="button"', body)

def test_login_htmx_partial_swap_has_toggle_and_script(self):
# Failed credentials + the HX-Request header make login_view return
# the swapped-in partial (account/partials/login_form.html), a bare
# fragment, not the full page. Assert the fragment carries both the
# toggle button and the idempotency-guard script, so an HTMX swap
# delivers everything the delegated handler needs. (assertNotIn
# '<html' confirms a fragment, not the full page, was returned —
# otherwise this test would silently pass even if django-htmx were
# inactive.)
resp = self.client.post(
reverse('login'),
{'email': 'toggle@nyasablog.com', 'password': 'wrong-password'}, # pragma: allowlist secret
HTTP_HX_REQUEST='true',
)
self.assertEqual(resp.status_code, 200)
body = resp.content.decode()
self.assertNotIn('<html', body)
self.assertEqual(body.count('aria-label="Show password"'), 1)
self.assertIn('__pwToggleBound', body)

def test_register_page_has_two_password_toggles(self):
resp = self.client.get(reverse('register'))
self.assertEqual(resp.status_code, 200)
body = resp.content.decode()
# RegistrationForm exposes password1 + password2. Count by
# aria-label (markup-only token), not `data-pw-toggle` which
# also appears in the delegated script's selector.
self.assertEqual(body.count('aria-label="Show password"'), 2)

def test_password_change_page_has_three_toggles(self):
self.client.force_login(self.user)
resp = self.client.get(reverse('password_change'))
self.assertEqual(resp.status_code, 200)
body = resp.content.decode()
# Count by aria-label (markup-only token), not `data-pw-toggle`.
self.assertEqual(body.count('aria-label="Show password"'), 3)

def test_password_reset_confirm_is_branded_with_two_toggles(self):
uid = urlsafe_base64_encode(force_bytes(self.user.pk))
token = default_token_generator.make_token(self.user)
resp = self.client.get(
reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token}),
follow=True,
)
self.assertEqual(resp.status_code, 200)
self.assertTemplateUsed(resp, 'registration/password_reset_confirm.html')
body = resp.content.decode()
# Branded: extends base.html, so the site name is present (admin
# fallback would not contain it).
self.assertIn('NyasaBlog', body)
# Count by aria-label (markup-only token), not `data-pw-toggle`.
self.assertEqual(body.count('aria-label="Show password"'), 2)
Loading
Loading