Adds /profile reachable via header username, with change-password form as its first section. Industry-standard session semantics: other sessions invalidated on password change, current session kept, via new users.password_changed_at column + session marker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1260 lines
39 KiB
Markdown
1260 lines
39 KiB
Markdown
# Profile page with self-service password change — implementation plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Logged-in users can change their own password from a new `/profile` page reachable by clicking their username in the header. Successful change invalidates every other active session for that user while keeping the current session signed in.
|
|
|
|
**Architecture:** New `password_changed_at` column on `users` plus a `session["pw_changed_at"]` marker. `load_current_user` already has the place to add a freshness check (mirroring the existing `user.active` rejection). A new `profile` blueprint owns `GET /profile` and `POST /profile/password`. The login rate-limit code is extracted into a tiny reusable helper.
|
|
|
|
**Tech Stack:** Flask, SQLAlchemy 2 ORM, Alembic, Werkzeug password hashing (`werkzeug.security`), Jinja2 templates, vendored HTMX (not needed here), pytest.
|
|
|
|
---
|
|
|
|
## File map
|
|
|
|
**New files**
|
|
- `l4d2web/alembic/versions/0009_user_password_changed_at.py` — Alembic migration adding `users.password_changed_at`.
|
|
- `l4d2web/services/rate_limit.py` — generic per-IP sliding-window check used by login and password change.
|
|
- `l4d2web/routes/profile_routes.py` — `GET /profile`, `POST /profile/password`.
|
|
- `l4d2web/templates/profile.html` — profile page; one "Change password" section.
|
|
- `l4d2web/tests/test_profile.py` — covers the new endpoint, validation branches, and cross-session invalidation.
|
|
|
|
**Modified files**
|
|
- `l4d2web/models.py` — add the `password_changed_at` column.
|
|
- `l4d2web/auth.py` — add `MIN_PASSWORD_LENGTH`, `validate_new_password`, freshness check in `load_current_user`, extend `login_user` signature.
|
|
- `l4d2web/routes/auth_routes.py` — pass `password_changed_at` to `login_user`; consume the shared rate-limit helper.
|
|
- `l4d2web/templates/base.html` — username `<span>` becomes `<a href="/profile">`.
|
|
- `l4d2web/app.py` — register the new blueprint.
|
|
|
|
---
|
|
|
|
## Task 1: Add `password_changed_at` to the User model
|
|
|
|
**Files:**
|
|
- Modify: `l4d2web/models.py:26-37`
|
|
- Test: `l4d2web/tests/test_models.py`
|
|
|
|
- [ ] **Step 1: Read the current model test**
|
|
|
|
Run: `cat l4d2web/tests/test_models.py`
|
|
|
|
It's a tiny smoke test. We'll add a new test alongside it.
|
|
|
|
- [ ] **Step 2: Write the failing test**
|
|
|
|
Append to `l4d2web/tests/test_models.py`:
|
|
|
|
```python
|
|
def test_user_has_password_changed_at_default(tmp_path, monkeypatch):
|
|
from datetime import UTC, datetime
|
|
from l4d2web.app import create_app
|
|
from l4d2web.auth import hash_password
|
|
from l4d2web.db import init_db, session_scope
|
|
from l4d2web.models import User
|
|
|
|
db_url = f"sqlite:///{tmp_path/'pw.db'}"
|
|
monkeypatch.setenv("DATABASE_URL", db_url)
|
|
create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
|
init_db()
|
|
|
|
before = datetime.now(UTC).replace(tzinfo=None)
|
|
with session_scope() as db:
|
|
db.add(User(username="alice", password_digest=hash_password("secret")))
|
|
with session_scope() as db:
|
|
user = db.query(User).filter_by(username="alice").one()
|
|
|
|
assert user.password_changed_at is not None
|
|
assert user.password_changed_at >= before
|
|
```
|
|
|
|
- [ ] **Step 3: Run the test — expect failure**
|
|
|
|
Run: `pytest l4d2web/tests/test_models.py::test_user_has_password_changed_at_default -v`
|
|
Expected: FAIL — `AttributeError: 'User' object has no attribute 'password_changed_at'`.
|
|
|
|
- [ ] **Step 4: Add the column to the model**
|
|
|
|
Edit `l4d2web/models.py` — in class `User`, add immediately after the `updated_at` column:
|
|
|
|
```python
|
|
password_changed_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
|
```
|
|
|
|
- [ ] **Step 5: Run the test — expect pass**
|
|
|
|
Run: `pytest l4d2web/tests/test_models.py::test_user_has_password_changed_at_default -v`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add l4d2web/models.py l4d2web/tests/test_models.py
|
|
git commit -m "models: add User.password_changed_at"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Alembic migration for `password_changed_at`
|
|
|
|
**Files:**
|
|
- Create: `l4d2web/alembic/versions/0009_user_password_changed_at.py`
|
|
- Test: `l4d2web/tests/test_alembic_migrations.py` (read-only check that the new revision is reachable; only add a test if the existing file already iterates revisions — otherwise skip; the migration is exercised end-to-end below)
|
|
|
|
- [ ] **Step 1: Inspect the previous migration head**
|
|
|
|
Run: `ls l4d2web/alembic/versions | sort`
|
|
Expected: latest non-merge revision is `0008_user_active`.
|
|
|
|
- [ ] **Step 2: Create the migration file**
|
|
|
|
Create `l4d2web/alembic/versions/0009_user_password_changed_at.py`:
|
|
|
|
```python
|
|
"""users.password_changed_at
|
|
|
|
Revision ID: 0009_user_password_changed_at
|
|
Revises: 0008_user_active
|
|
Create Date: 2026-05-11
|
|
"""
|
|
from typing import Sequence, Union
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
|
|
|
|
revision: str = "0009_user_password_changed_at"
|
|
down_revision: Union[str, Sequence[str], None] = "0008_user_active"
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
# Add the column nullable, backfill with created_at so historical rows
|
|
# have a sane marker, then enforce NOT NULL.
|
|
with op.batch_alter_table("users") as batch_op:
|
|
batch_op.add_column(sa.Column("password_changed_at", sa.DateTime(), nullable=True))
|
|
op.execute("UPDATE users SET password_changed_at = created_at WHERE password_changed_at IS NULL")
|
|
with op.batch_alter_table("users") as batch_op:
|
|
batch_op.alter_column("password_changed_at", nullable=False)
|
|
|
|
|
|
def downgrade() -> None:
|
|
with op.batch_alter_table("users") as batch_op:
|
|
batch_op.drop_column("password_changed_at")
|
|
```
|
|
|
|
- [ ] **Step 3: Run the migration against a scratch DB**
|
|
|
|
```bash
|
|
rm -f /tmp/l4d2web_migration_check.db
|
|
DATABASE_URL=sqlite:////tmp/l4d2web_migration_check.db alembic -c l4d2web/alembic.ini upgrade head
|
|
DATABASE_URL=sqlite:////tmp/l4d2web_migration_check.db alembic -c l4d2web/alembic.ini downgrade -1
|
|
DATABASE_URL=sqlite:////tmp/l4d2web_migration_check.db alembic -c l4d2web/alembic.ini upgrade head
|
|
```
|
|
|
|
Expected: each command exits 0; second `upgrade` reports `Running upgrade 0008_user_active -> 0009_user_password_changed_at`.
|
|
|
|
- [ ] **Step 4: Run the existing migration test (if any)**
|
|
|
|
Run: `pytest l4d2web/tests/test_alembic_migrations.py -v`
|
|
Expected: PASS — covers that all revisions are reachable from base to head.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add l4d2web/alembic/versions/0009_user_password_changed_at.py
|
|
git commit -m "alembic: add users.password_changed_at column"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: `validate_new_password` helper and `MIN_PASSWORD_LENGTH`
|
|
|
|
**Files:**
|
|
- Modify: `l4d2web/auth.py:16-22`
|
|
- Test: `l4d2web/tests/test_auth.py` (append)
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Append to `l4d2web/tests/test_auth.py`:
|
|
|
|
```python
|
|
def test_validate_new_password_rejects_empty():
|
|
from l4d2web.auth import validate_new_password
|
|
assert validate_new_password("") == "password must not be empty"
|
|
|
|
|
|
def test_validate_new_password_rejects_short():
|
|
from l4d2web.auth import validate_new_password, MIN_PASSWORD_LENGTH
|
|
assert MIN_PASSWORD_LENGTH == 8
|
|
assert validate_new_password("a" * 7) == "password must be at least 8 characters"
|
|
|
|
|
|
def test_validate_new_password_accepts_min_length():
|
|
from l4d2web.auth import validate_new_password
|
|
assert validate_new_password("a" * 8) is None
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests — expect failure**
|
|
|
|
Run: `pytest l4d2web/tests/test_auth.py -v -k validate_new_password`
|
|
Expected: 3 FAIL — `ImportError: cannot import name 'validate_new_password'`.
|
|
|
|
- [ ] **Step 3: Implement the helper**
|
|
|
|
Edit `l4d2web/auth.py` — insert after the existing `verify_password` function:
|
|
|
|
```python
|
|
MIN_PASSWORD_LENGTH = 8
|
|
|
|
|
|
def validate_new_password(raw: str) -> str | None:
|
|
if raw == "":
|
|
return "password must not be empty"
|
|
if len(raw) < MIN_PASSWORD_LENGTH:
|
|
return f"password must be at least {MIN_PASSWORD_LENGTH} characters"
|
|
return None
|
|
```
|
|
|
|
- [ ] **Step 4: Run the tests — expect pass**
|
|
|
|
Run: `pytest l4d2web/tests/test_auth.py -v -k validate_new_password`
|
|
Expected: 3 PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add l4d2web/auth.py l4d2web/tests/test_auth.py
|
|
git commit -m "auth: validate_new_password helper (min length 8)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Generic rate-limit helper, refactor login to use it
|
|
|
|
**Files:**
|
|
- Create: `l4d2web/services/rate_limit.py`
|
|
- Modify: `l4d2web/routes/auth_routes.py:13-30`
|
|
- Test: `l4d2web/tests/test_rate_limit.py` (new)
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `l4d2web/tests/test_rate_limit.py`:
|
|
|
|
```python
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from l4d2web.services.rate_limit import check_rate_limit
|
|
|
|
|
|
def test_under_threshold_allows():
|
|
bucket: dict[str, list[float]] = {}
|
|
for _ in range(3):
|
|
assert check_rate_limit(bucket, "1.2.3.4", window=60.0, max_attempts=5) is False
|
|
|
|
|
|
def test_at_threshold_blocks():
|
|
bucket: dict[str, list[float]] = {}
|
|
for _ in range(5):
|
|
assert check_rate_limit(bucket, "1.2.3.4", window=60.0, max_attempts=5) is False
|
|
assert check_rate_limit(bucket, "1.2.3.4", window=60.0, max_attempts=5) is True
|
|
|
|
|
|
def test_other_ips_independent():
|
|
bucket: dict[str, list[float]] = {}
|
|
for _ in range(5):
|
|
check_rate_limit(bucket, "1.2.3.4", window=60.0, max_attempts=5)
|
|
assert check_rate_limit(bucket, "5.6.7.8", window=60.0, max_attempts=5) is False
|
|
|
|
|
|
def test_old_attempts_expire():
|
|
bucket: dict[str, list[float]] = {}
|
|
for _ in range(5):
|
|
check_rate_limit(bucket, "1.2.3.4", window=0.05, max_attempts=5)
|
|
time.sleep(0.1)
|
|
assert check_rate_limit(bucket, "1.2.3.4", window=0.05, max_attempts=5) is False
|
|
```
|
|
|
|
- [ ] **Step 2: Run — expect failure**
|
|
|
|
Run: `pytest l4d2web/tests/test_rate_limit.py -v`
|
|
Expected: FAIL — `ModuleNotFoundError: No module named 'l4d2web.services.rate_limit'`.
|
|
|
|
- [ ] **Step 3: Implement the helper**
|
|
|
|
Create `l4d2web/services/rate_limit.py`:
|
|
|
|
```python
|
|
import time
|
|
|
|
|
|
def check_rate_limit(
|
|
bucket: dict[str, list[float]],
|
|
remote_addr: str,
|
|
*,
|
|
window: float,
|
|
max_attempts: int,
|
|
) -> bool:
|
|
"""Return True if the request should be blocked.
|
|
|
|
bucket is a caller-owned per-endpoint store; this lets tests reset state
|
|
cleanly and keeps login/profile counts independent.
|
|
"""
|
|
now = time.time()
|
|
attempts = bucket.setdefault(remote_addr, [])
|
|
cutoff = now - window
|
|
attempts[:] = [ts for ts in attempts if ts >= cutoff]
|
|
if len(attempts) >= max_attempts:
|
|
return True
|
|
attempts.append(now)
|
|
return False
|
|
```
|
|
|
|
- [ ] **Step 4: Run — expect pass**
|
|
|
|
Run: `pytest l4d2web/tests/test_rate_limit.py -v`
|
|
Expected: 4 PASS.
|
|
|
|
- [ ] **Step 5: Refactor `auth_routes.py` to use the helper**
|
|
|
|
Edit `l4d2web/routes/auth_routes.py`:
|
|
|
|
Replace lines 1-30 with:
|
|
|
|
```python
|
|
import time
|
|
|
|
from flask import Blueprint, Response, redirect, render_template, request
|
|
from sqlalchemy import select
|
|
|
|
from l4d2web.auth import hash_password, is_safe_next, login_user, logout_user, verify_password
|
|
from l4d2web.db import session_scope
|
|
from l4d2web.models import User
|
|
from l4d2web.services.rate_limit import check_rate_limit
|
|
|
|
|
|
bp = Blueprint("auth", __name__)
|
|
_TIMING_DUMMY_DIGEST = hash_password("__timing_dummy__")
|
|
LOGIN_RATE_LIMIT_WINDOW_SECONDS = 60.0
|
|
LOGIN_RATE_LIMIT_MAX_ATTEMPTS = 20
|
|
LOGIN_ATTEMPTS_BY_IP: dict[str, list[float]] = {}
|
|
|
|
|
|
def reset_login_rate_limits() -> None:
|
|
LOGIN_ATTEMPTS_BY_IP.clear()
|
|
|
|
|
|
def is_login_rate_limited(remote_addr: str) -> bool:
|
|
return check_rate_limit(
|
|
LOGIN_ATTEMPTS_BY_IP,
|
|
remote_addr,
|
|
window=LOGIN_RATE_LIMIT_WINDOW_SECONDS,
|
|
max_attempts=LOGIN_RATE_LIMIT_MAX_ATTEMPTS,
|
|
)
|
|
```
|
|
|
|
Note: the unused `import time` at the top of the original file can be removed in this rewrite — it was only needed for the old inline `time.time()` calls.
|
|
|
|
- [ ] **Step 6: Run the full auth test file to make sure nothing regressed**
|
|
|
|
Run: `pytest l4d2web/tests/test_auth.py -v`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add l4d2web/services/rate_limit.py l4d2web/routes/auth_routes.py l4d2web/tests/test_rate_limit.py
|
|
git commit -m "rate-limit: extract generic helper, reuse from login"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: `login_user` stamps `password_changed_at` into the session
|
|
|
|
**Files:**
|
|
- Modify: `l4d2web/auth.py:40-42`
|
|
- Modify: `l4d2web/routes/auth_routes.py` (caller)
|
|
- Test: `l4d2web/tests/test_auth.py` (append)
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append to `l4d2web/tests/test_auth.py`:
|
|
|
|
```python
|
|
def test_login_stamps_password_changed_at_in_session(client) -> None:
|
|
with session_scope() as session:
|
|
session.add(User(username="alice", password_digest=hash_password("secret")))
|
|
|
|
response = client.post("/login", data={"username": "alice", "password": "secret"})
|
|
assert response.status_code == 302
|
|
|
|
with client.session_transaction() as sess:
|
|
marker = sess.get("pw_changed_at")
|
|
assert marker is not None
|
|
with session_scope() as session:
|
|
user = session.query(User).filter_by(username="alice").one()
|
|
assert marker == user.password_changed_at.isoformat()
|
|
```
|
|
|
|
- [ ] **Step 2: Run — expect failure**
|
|
|
|
Run: `pytest l4d2web/tests/test_auth.py::test_login_stamps_password_changed_at_in_session -v`
|
|
Expected: FAIL — `marker is None`.
|
|
|
|
- [ ] **Step 3: Update `login_user` signature in `l4d2web/auth.py`**
|
|
|
|
Replace the existing `login_user` function (currently `l4d2web/auth.py:40-42`):
|
|
|
|
```python
|
|
def login_user(user_id: int, password_changed_at) -> None:
|
|
session["user_id"] = user_id
|
|
session["pw_changed_at"] = password_changed_at.isoformat()
|
|
```
|
|
|
|
(No type hint on the second parameter to avoid adding a `datetime` import; it's straightforward at the call site.)
|
|
|
|
- [ ] **Step 4: Update the only caller**
|
|
|
|
In `l4d2web/routes/auth_routes.py`, change the line `login_user(user.id)` to:
|
|
|
|
```python
|
|
login_user(user.id, user.password_changed_at)
|
|
```
|
|
|
|
- [ ] **Step 5: Run — expect pass**
|
|
|
|
Run: `pytest l4d2web/tests/test_auth.py::test_login_stamps_password_changed_at_in_session -v`
|
|
Expected: PASS.
|
|
|
|
Then run the full auth file: `pytest l4d2web/tests/test_auth.py -v` — expect all PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add l4d2web/auth.py l4d2web/routes/auth_routes.py l4d2web/tests/test_auth.py
|
|
git commit -m "auth: stamp password_changed_at marker in session on login"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: `load_current_user` rejects stale session markers
|
|
|
|
**Files:**
|
|
- Modify: `l4d2web/auth.py:24-33`
|
|
- Test: `l4d2web/tests/test_auth.py` (append)
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Append to `l4d2web/tests/test_auth.py`:
|
|
|
|
```python
|
|
def test_load_current_user_rejects_missing_marker(client) -> None:
|
|
with session_scope() as db:
|
|
u = User(username="alice", password_digest=hash_password("secret"))
|
|
db.add(u)
|
|
db.flush()
|
|
uid = u.id
|
|
|
|
# Forge a pre-marker session.
|
|
with client.session_transaction() as sess:
|
|
sess["user_id"] = uid
|
|
|
|
response = client.get("/dashboard")
|
|
assert response.status_code == 302
|
|
assert "/login" in response.headers["Location"]
|
|
|
|
|
|
def test_load_current_user_rejects_stale_marker(client) -> None:
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
with session_scope() as db:
|
|
u = User(username="alice", password_digest=hash_password("secret"))
|
|
db.add(u)
|
|
db.flush()
|
|
uid = u.id
|
|
|
|
# Forge a session that knew about an earlier password_changed_at.
|
|
stale = datetime.now(UTC).replace(tzinfo=None) - timedelta(minutes=5)
|
|
with client.session_transaction() as sess:
|
|
sess["user_id"] = uid
|
|
sess["pw_changed_at"] = stale.isoformat()
|
|
|
|
response = client.get("/dashboard")
|
|
assert response.status_code == 302
|
|
assert "/login" in response.headers["Location"]
|
|
|
|
|
|
def test_load_current_user_accepts_current_marker(client) -> None:
|
|
with session_scope() as db:
|
|
u = User(username="alice", password_digest=hash_password("secret"))
|
|
db.add(u)
|
|
db.flush()
|
|
uid = u.id
|
|
marker = u.password_changed_at.isoformat()
|
|
|
|
with client.session_transaction() as sess:
|
|
sess["user_id"] = uid
|
|
sess["pw_changed_at"] = marker
|
|
|
|
response = client.get("/dashboard")
|
|
assert response.status_code == 200
|
|
```
|
|
|
|
- [ ] **Step 2: Run — expect 2 of 3 to fail**
|
|
|
|
Run: `pytest l4d2web/tests/test_auth.py -v -k load_current_user`
|
|
Expected: the "accepts current marker" test already passes (the freshness check doesn't exist yet); the "missing marker" and "stale marker" tests FAIL because we currently let any session in.
|
|
|
|
- [ ] **Step 3: Add the freshness check**
|
|
|
|
First add `from datetime import datetime` to the top of `l4d2web/auth.py` (under the other stdlib imports).
|
|
|
|
Then replace the body of `load_current_user` in `l4d2web/auth.py` (currently lines 24-33):
|
|
|
|
```python
|
|
def load_current_user() -> None:
|
|
user_id = session.get("user_id")
|
|
if user_id is None:
|
|
g.user = None
|
|
return
|
|
with session_scope() as db:
|
|
user = db.scalar(select(User).where(User.id == int(user_id)))
|
|
if user is None or not user.active:
|
|
g.user = None
|
|
return
|
|
|
|
marker = session.get("pw_changed_at")
|
|
if marker is None:
|
|
g.user = None
|
|
return
|
|
try:
|
|
marker_dt = datetime.fromisoformat(marker)
|
|
except ValueError:
|
|
g.user = None
|
|
return
|
|
if marker_dt < user.password_changed_at:
|
|
g.user = None
|
|
return
|
|
|
|
g.user = user
|
|
```
|
|
|
|
- [ ] **Step 4: Run — expect all pass**
|
|
|
|
Run: `pytest l4d2web/tests/test_auth.py -v`
|
|
Expected: full file PASS.
|
|
|
|
- [ ] **Step 5: Sanity-check the admin tests still pass**
|
|
|
|
Run: `pytest l4d2web/tests/test_admin_users.py -v`
|
|
Expected: PASS. The existing `test_deactivated_user_existing_session_invalidated` forges a session without `pw_changed_at`; now that the marker is required, the test should still pass for the same reason (just via a different rejection path). If it regresses because a different test seeds a session without the marker, update the test to include `sess["pw_changed_at"] = u.password_changed_at.isoformat()` where the seeding happens.
|
|
|
|
- [ ] **Step 6: Run the full suite**
|
|
|
|
Run: `pytest l4d2web/tests -q`
|
|
Expected: PASS. If any test relies on `client.session_transaction()` to forge a `user_id` without the marker, patch it to also set `pw_changed_at` from the user's `password_changed_at`. Likely candidates: `test_admin_users.py:131-133`, `test_admin_users.py:30-32`. Update those to:
|
|
|
|
```python
|
|
with bob_client.session_transaction() as sess:
|
|
sess["user_id"] = target
|
|
sess["pw_changed_at"] = <user>.password_changed_at.isoformat()
|
|
sess["csrf_token"] = "test-token"
|
|
```
|
|
|
|
(Fetch `password_changed_at` from the DB in the test fixture's `with session_scope()` block.)
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add l4d2web/auth.py l4d2web/tests/test_auth.py l4d2web/tests/test_admin_users.py
|
|
git commit -m "auth: reject sessions older than user.password_changed_at"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Profile page — `GET /profile` and template
|
|
|
|
**Files:**
|
|
- Create: `l4d2web/routes/profile_routes.py`
|
|
- Create: `l4d2web/templates/profile.html`
|
|
- Modify: `l4d2web/templates/base.html:27`
|
|
- Modify: `l4d2web/app.py` (register blueprint)
|
|
- Test: `l4d2web/tests/test_profile.py` (new)
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `l4d2web/tests/test_profile.py`:
|
|
|
|
```python
|
|
import pytest
|
|
|
|
from l4d2web.app import create_app
|
|
from l4d2web.auth import hash_password
|
|
from l4d2web.db import init_db, session_scope
|
|
from l4d2web.models import User
|
|
|
|
|
|
@pytest.fixture
|
|
def app_and_user(tmp_path, monkeypatch):
|
|
db_url = f"sqlite:///{tmp_path/'profile.db'}"
|
|
monkeypatch.setenv("DATABASE_URL", db_url)
|
|
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
|
init_db()
|
|
with session_scope() as db:
|
|
u = User(username="alice", password_digest=hash_password("currentpass"))
|
|
db.add(u)
|
|
db.flush()
|
|
uid = u.id
|
|
marker = u.password_changed_at.isoformat()
|
|
return app, uid, marker
|
|
|
|
|
|
def _logged_in_client(app, uid, marker):
|
|
client = app.test_client()
|
|
with client.session_transaction() as sess:
|
|
sess["user_id"] = uid
|
|
sess["pw_changed_at"] = marker
|
|
sess["csrf_token"] = "test-token"
|
|
return client
|
|
|
|
|
|
def test_profile_requires_login(app_and_user):
|
|
app, _, _ = app_and_user
|
|
response = app.test_client().get("/profile", follow_redirects=False)
|
|
assert response.status_code == 302
|
|
assert "/login" in response.headers["Location"]
|
|
|
|
|
|
def test_profile_page_renders(app_and_user):
|
|
app, uid, marker = app_and_user
|
|
client = _logged_in_client(app, uid, marker)
|
|
response = client.get("/profile")
|
|
assert response.status_code == 200
|
|
body = response.get_data(as_text=True)
|
|
assert "Change password" in body
|
|
assert 'name="current_password"' in body
|
|
assert 'name="new_password"' in body
|
|
assert 'name="confirm_new_password"' in body
|
|
|
|
|
|
def test_base_template_links_username_to_profile(app_and_user):
|
|
app, uid, marker = app_and_user
|
|
client = _logged_in_client(app, uid, marker)
|
|
response = client.get("/dashboard")
|
|
body = response.get_data(as_text=True)
|
|
assert 'href="/profile"' in body
|
|
assert ">alice<" in body
|
|
```
|
|
|
|
- [ ] **Step 2: Run — expect failure**
|
|
|
|
Run: `pytest l4d2web/tests/test_profile.py -v`
|
|
Expected: all FAIL — `/profile` route doesn't exist yet.
|
|
|
|
- [ ] **Step 3: Create the profile route**
|
|
|
|
Create `l4d2web/routes/profile_routes.py`:
|
|
|
|
```python
|
|
from flask import Blueprint, render_template, request
|
|
|
|
from l4d2web.auth import require_login
|
|
|
|
|
|
bp = Blueprint("profile", __name__)
|
|
|
|
|
|
_ERROR_MESSAGES = {
|
|
"fields_required": "Fill in all three fields.",
|
|
"mismatch": "New password and confirmation do not match.",
|
|
"too_short": "New password must be at least 8 characters.",
|
|
"empty": "New password must not be empty.",
|
|
"wrong_current": "Current password is incorrect.",
|
|
}
|
|
|
|
|
|
@bp.get("/profile")
|
|
@require_login
|
|
def profile_page() -> str:
|
|
error_key = request.args.get("error", "")
|
|
success = request.args.get("success") == "1"
|
|
return render_template(
|
|
"profile.html",
|
|
error_message=_ERROR_MESSAGES.get(error_key, ""),
|
|
success=success,
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 4: Create the template**
|
|
|
|
Create `l4d2web/templates/profile.html`:
|
|
|
|
```html
|
|
{% extends "base.html" %}
|
|
|
|
{% block title %}Profile | left4me{% endblock %}
|
|
|
|
{% block content %}
|
|
<section class="panel">
|
|
<h1>Change password</h1>
|
|
|
|
{% if success %}
|
|
<p class="muted">Password changed.</p>
|
|
{% endif %}
|
|
{% if error_message %}
|
|
<p class="error">{{ error_message }}</p>
|
|
{% endif %}
|
|
|
|
<form method="post" action="/profile/password" class="form">
|
|
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
|
<label>
|
|
Current password
|
|
<input type="password" name="current_password" autocomplete="current-password" required>
|
|
</label>
|
|
<label>
|
|
New password
|
|
<input type="password" name="new_password" autocomplete="new-password" required>
|
|
</label>
|
|
<label>
|
|
Confirm new password
|
|
<input type="password" name="confirm_new_password" autocomplete="new-password" required>
|
|
</label>
|
|
<button type="submit">Change password</button>
|
|
</form>
|
|
</section>
|
|
{% endblock %}
|
|
```
|
|
|
|
- [ ] **Step 5: Link the username in the header**
|
|
|
|
Edit `l4d2web/templates/base.html` line 27. Replace:
|
|
|
|
```
|
|
<span class="muted">{{ g.user.username }}</span>
|
|
```
|
|
|
|
with:
|
|
|
|
```
|
|
<a class="muted" href="/profile">{{ g.user.username }}</a>
|
|
```
|
|
|
|
- [ ] **Step 6: Register the blueprint**
|
|
|
|
Edit `l4d2web/app.py`. In the import block, add:
|
|
|
|
```python
|
|
from l4d2web.routes.profile_routes import bp as profile_bp
|
|
```
|
|
|
|
In the `register_blueprint` block (immediately after `app.register_blueprint(page_bp)`), add:
|
|
|
|
```python
|
|
app.register_blueprint(profile_bp)
|
|
```
|
|
|
|
- [ ] **Step 7: Run the test file — expect pass**
|
|
|
|
Run: `pytest l4d2web/tests/test_profile.py -v`
|
|
Expected: 3 PASS.
|
|
|
|
- [ ] **Step 8: Run the full suite — sanity**
|
|
|
|
Run: `pytest l4d2web/tests -q`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add l4d2web/routes/profile_routes.py l4d2web/templates/profile.html l4d2web/templates/base.html l4d2web/app.py l4d2web/tests/test_profile.py
|
|
git commit -m "profile: GET /profile page with change-password form"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: `POST /profile/password` — validation branches
|
|
|
|
**Files:**
|
|
- Modify: `l4d2web/routes/profile_routes.py`
|
|
- Test: `l4d2web/tests/test_profile.py` (append)
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Append to `l4d2web/tests/test_profile.py`:
|
|
|
|
```python
|
|
from sqlalchemy import select
|
|
|
|
|
|
def _post_pw(client, **fields):
|
|
payload = {"csrf_token": "test-token", **fields}
|
|
return client.post("/profile/password", data=payload, follow_redirects=False)
|
|
|
|
|
|
def _digest_of(username):
|
|
with session_scope() as db:
|
|
u = db.scalar(select(User).where(User.username == username))
|
|
return u.password_digest
|
|
|
|
|
|
def test_post_password_rejects_missing_csrf(app_and_user):
|
|
app, uid, marker = app_and_user
|
|
client = _logged_in_client(app, uid, marker)
|
|
before = _digest_of("alice")
|
|
response = client.post(
|
|
"/profile/password",
|
|
data={"current_password": "currentpass", "new_password": "newpass12", "confirm_new_password": "newpass12"},
|
|
)
|
|
assert response.status_code == 400
|
|
assert _digest_of("alice") == before
|
|
|
|
|
|
def test_post_password_requires_login(app_and_user):
|
|
app, _, _ = app_and_user
|
|
response = app.test_client().post(
|
|
"/profile/password",
|
|
data={"csrf_token": "test-token", "current_password": "x", "new_password": "y" * 8, "confirm_new_password": "y" * 8},
|
|
follow_redirects=False,
|
|
)
|
|
assert response.status_code == 302
|
|
assert "/login" in response.headers["Location"]
|
|
|
|
|
|
def test_post_password_empty_fields_redirects_with_error(app_and_user):
|
|
app, uid, marker = app_and_user
|
|
client = _logged_in_client(app, uid, marker)
|
|
before = _digest_of("alice")
|
|
response = _post_pw(client, current_password="", new_password="", confirm_new_password="")
|
|
assert response.status_code == 302
|
|
assert "/profile?error=fields_required" in response.headers["Location"]
|
|
assert _digest_of("alice") == before
|
|
|
|
|
|
def test_post_password_mismatched_confirm(app_and_user):
|
|
app, uid, marker = app_and_user
|
|
client = _logged_in_client(app, uid, marker)
|
|
before = _digest_of("alice")
|
|
response = _post_pw(
|
|
client,
|
|
current_password="currentpass",
|
|
new_password="newpass12",
|
|
confirm_new_password="newpass99",
|
|
)
|
|
assert response.status_code == 302
|
|
assert "/profile?error=mismatch" in response.headers["Location"]
|
|
assert _digest_of("alice") == before
|
|
|
|
|
|
def test_post_password_too_short(app_and_user):
|
|
app, uid, marker = app_and_user
|
|
client = _logged_in_client(app, uid, marker)
|
|
before = _digest_of("alice")
|
|
response = _post_pw(
|
|
client,
|
|
current_password="currentpass",
|
|
new_password="short7x",
|
|
confirm_new_password="short7x",
|
|
)
|
|
assert response.status_code == 302
|
|
assert "/profile?error=too_short" in response.headers["Location"]
|
|
assert _digest_of("alice") == before
|
|
|
|
|
|
def test_post_password_wrong_current(app_and_user):
|
|
app, uid, marker = app_and_user
|
|
client = _logged_in_client(app, uid, marker)
|
|
before = _digest_of("alice")
|
|
response = _post_pw(
|
|
client,
|
|
current_password="WRONG",
|
|
new_password="newpass12",
|
|
confirm_new_password="newpass12",
|
|
)
|
|
assert response.status_code == 302
|
|
assert "/profile?error=wrong_current" in response.headers["Location"]
|
|
assert _digest_of("alice") == before
|
|
```
|
|
|
|
- [ ] **Step 2: Run — expect failure**
|
|
|
|
Run: `pytest l4d2web/tests/test_profile.py -v -k post_password`
|
|
Expected: all FAIL — `/profile/password` route doesn't exist (404 / 405).
|
|
|
|
- [ ] **Step 3: Implement the endpoint**
|
|
|
|
Append to `l4d2web/routes/profile_routes.py`:
|
|
|
|
```python
|
|
from flask import Response, current_app, redirect, request
|
|
from sqlalchemy import select
|
|
|
|
from l4d2web.auth import (
|
|
current_user,
|
|
hash_password,
|
|
validate_new_password,
|
|
verify_password,
|
|
)
|
|
from l4d2web.db import session_scope
|
|
from l4d2web.models import User, now_utc
|
|
from l4d2web.services.rate_limit import check_rate_limit
|
|
|
|
|
|
PROFILE_PW_RATE_LIMIT_WINDOW_SECONDS = 60.0
|
|
PROFILE_PW_RATE_LIMIT_MAX_ATTEMPTS = 10
|
|
PROFILE_PW_ATTEMPTS_BY_IP: dict[str, list[float]] = {}
|
|
|
|
|
|
def reset_profile_password_rate_limits() -> None:
|
|
PROFILE_PW_ATTEMPTS_BY_IP.clear()
|
|
|
|
|
|
def _redirect_with_error(error_key: str) -> Response:
|
|
return redirect(f"/profile?error={error_key}")
|
|
|
|
|
|
@bp.post("/profile/password")
|
|
@require_login
|
|
def profile_password_change() -> Response:
|
|
remote_addr = request.remote_addr or "unknown"
|
|
if check_rate_limit(
|
|
PROFILE_PW_ATTEMPTS_BY_IP,
|
|
remote_addr,
|
|
window=PROFILE_PW_RATE_LIMIT_WINDOW_SECONDS,
|
|
max_attempts=PROFILE_PW_RATE_LIMIT_MAX_ATTEMPTS,
|
|
):
|
|
return Response("too many attempts", status=429)
|
|
|
|
current_password = request.form.get("current_password", "")
|
|
new_password = request.form.get("new_password", "")
|
|
confirm_new_password = request.form.get("confirm_new_password", "")
|
|
|
|
if not current_password or not new_password or not confirm_new_password:
|
|
return _redirect_with_error("fields_required")
|
|
if new_password != confirm_new_password:
|
|
return _redirect_with_error("mismatch")
|
|
policy_error = validate_new_password(new_password)
|
|
if policy_error is not None:
|
|
return _redirect_with_error(
|
|
"empty" if policy_error == "password must not be empty" else "too_short"
|
|
)
|
|
|
|
actor = current_user()
|
|
assert actor is not None # @require_login
|
|
|
|
with session_scope() as db:
|
|
user = db.scalar(select(User).where(User.id == actor.id))
|
|
if user is None or not verify_password(current_password, user.password_digest):
|
|
return _redirect_with_error("wrong_current")
|
|
user.password_digest = hash_password(new_password)
|
|
user.password_changed_at = now_utc()
|
|
new_marker = user.password_changed_at.isoformat()
|
|
|
|
# Re-stamp the current session so this browser stays logged in;
|
|
# any other session whose marker is older is rejected by load_current_user.
|
|
from flask import session
|
|
session["pw_changed_at"] = new_marker
|
|
|
|
return redirect("/profile?success=1")
|
|
```
|
|
|
|
(The duplicate `from flask import ...` at the top of the appended block is fine — Python allows it. If you prefer, merge it into the existing `from flask import Blueprint, render_template, request` line at the top of the file: `from flask import Blueprint, Response, redirect, render_template, request, session`.)
|
|
|
|
- [ ] **Step 4: Reset rate limit when testing**
|
|
|
|
Edit `l4d2web/app.py` — in the `if app.config.get("TESTING"):` block (near `reset_login_rate_limits()`), add a call to the profile reset. First add the import alongside `reset_login_rate_limits`:
|
|
|
|
```python
|
|
from l4d2web.routes.profile_routes import reset_profile_password_rate_limits
|
|
```
|
|
|
|
Then in the test branch:
|
|
|
|
```python
|
|
if app.config.get("TESTING"):
|
|
reset_login_rate_limits()
|
|
reset_profile_password_rate_limits()
|
|
```
|
|
|
|
- [ ] **Step 5: Run the new tests — expect pass**
|
|
|
|
Run: `pytest l4d2web/tests/test_profile.py -v`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Run the full suite — sanity**
|
|
|
|
Run: `pytest l4d2web/tests -q`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add l4d2web/routes/profile_routes.py l4d2web/app.py l4d2web/tests/test_profile.py
|
|
git commit -m "profile: POST /profile/password validation branches"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Happy path — password actually rotates and current session survives
|
|
|
|
**Files:**
|
|
- Test: `l4d2web/tests/test_profile.py` (append)
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Append to `l4d2web/tests/test_profile.py`:
|
|
|
|
```python
|
|
def test_post_password_happy_path_rotates_and_keeps_current_session(app_and_user):
|
|
app, uid, marker = app_and_user
|
|
client = _logged_in_client(app, uid, marker)
|
|
before_digest = _digest_of("alice")
|
|
with session_scope() as db:
|
|
before_marker = db.scalar(select(User).where(User.id == uid)).password_changed_at
|
|
|
|
response = _post_pw(
|
|
client,
|
|
current_password="currentpass",
|
|
new_password="newpass12",
|
|
confirm_new_password="newpass12",
|
|
)
|
|
|
|
assert response.status_code == 302
|
|
assert response.headers["Location"].endswith("/profile?success=1")
|
|
|
|
# Password changed.
|
|
after_digest = _digest_of("alice")
|
|
assert after_digest != before_digest
|
|
with session_scope() as db:
|
|
u = db.scalar(select(User).where(User.id == uid))
|
|
assert u.password_changed_at > before_marker
|
|
new_marker = u.password_changed_at.isoformat()
|
|
|
|
# Current session still authenticated.
|
|
follow = client.get("/dashboard")
|
|
assert follow.status_code == 200
|
|
|
|
# Session marker was re-stamped to the new value.
|
|
with client.session_transaction() as sess:
|
|
assert sess["pw_changed_at"] == new_marker
|
|
|
|
|
|
def test_other_session_dies_after_password_change(app_and_user):
|
|
app, uid, marker = app_and_user
|
|
primary = _logged_in_client(app, uid, marker)
|
|
other = _logged_in_client(app, uid, marker)
|
|
|
|
# Other session works before the change.
|
|
pre = other.get("/dashboard")
|
|
assert pre.status_code == 200
|
|
|
|
response = _post_pw(
|
|
primary,
|
|
current_password="currentpass",
|
|
new_password="newpass12",
|
|
confirm_new_password="newpass12",
|
|
)
|
|
assert response.status_code == 302
|
|
|
|
# Other session is now stale.
|
|
post = other.get("/dashboard", follow_redirects=False)
|
|
assert post.status_code == 302
|
|
assert "/login" in post.headers["Location"]
|
|
|
|
|
|
def test_new_password_works_for_login(app_and_user):
|
|
app, uid, marker = app_and_user
|
|
client = _logged_in_client(app, uid, marker)
|
|
_post_pw(
|
|
client,
|
|
current_password="currentpass",
|
|
new_password="newpass12",
|
|
confirm_new_password="newpass12",
|
|
)
|
|
|
|
fresh = app.test_client()
|
|
response = fresh.post(
|
|
"/login",
|
|
data={"username": "alice", "password": "newpass12"},
|
|
headers={"X-CSRF-Token": "test-token"},
|
|
)
|
|
assert response.status_code == 302
|
|
assert response.headers["Location"].endswith("/dashboard")
|
|
```
|
|
|
|
- [ ] **Step 2: Run — expect pass already**
|
|
|
|
Run: `pytest l4d2web/tests/test_profile.py -v -k happy_path`
|
|
Expected: PASS (Task 8 already implemented this).
|
|
|
|
Run the cross-session test: `pytest l4d2web/tests/test_profile.py::test_other_session_dies_after_password_change -v`
|
|
Expected: PASS.
|
|
|
|
Run the login-with-new-password test: `pytest l4d2web/tests/test_profile.py::test_new_password_works_for_login -v`
|
|
Expected: PASS.
|
|
|
|
If any of these fail, the bug is in Task 6 (stale-marker check) or Task 8 (digest rotation / re-stamp). Fix before continuing.
|
|
|
|
- [ ] **Step 3: Full suite**
|
|
|
|
Run: `pytest l4d2web/tests -q`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add l4d2web/tests/test_profile.py
|
|
git commit -m "profile: happy-path + cross-session invalidation tests"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Rate-limit test on `POST /profile/password`
|
|
|
|
**Files:**
|
|
- Test: `l4d2web/tests/test_profile.py` (append)
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append to `l4d2web/tests/test_profile.py`:
|
|
|
|
```python
|
|
def test_post_password_rate_limited(app_and_user):
|
|
from l4d2web.routes.profile_routes import (
|
|
PROFILE_PW_RATE_LIMIT_MAX_ATTEMPTS,
|
|
reset_profile_password_rate_limits,
|
|
)
|
|
reset_profile_password_rate_limits()
|
|
|
|
app, uid, marker = app_and_user
|
|
client = _logged_in_client(app, uid, marker)
|
|
|
|
# Use a deliberately-wrong current password so we never actually rotate.
|
|
for _ in range(PROFILE_PW_RATE_LIMIT_MAX_ATTEMPTS):
|
|
_post_pw(client, current_password="WRONG", new_password="newpass12", confirm_new_password="newpass12")
|
|
|
|
blocked = _post_pw(client, current_password="WRONG", new_password="newpass12", confirm_new_password="newpass12")
|
|
assert blocked.status_code == 429
|
|
```
|
|
|
|
- [ ] **Step 2: Run — expect pass**
|
|
|
|
Run: `pytest l4d2web/tests/test_profile.py::test_post_password_rate_limited -v`
|
|
Expected: PASS.
|
|
|
|
(If it fails because the limit constant differs between the test file and the route module, prefer importing the constant from the route module — already done above.)
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add l4d2web/tests/test_profile.py
|
|
git commit -m "profile: rate-limit test for /profile/password"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11 (optional): Apply `validate_new_password` to `create-user` CLI
|
|
|
|
This task is *optional polish* — keeps the password policy unified across all surfaces. Skip if the user prefers to keep CLI behavior unchanged.
|
|
|
|
**Files:**
|
|
- Modify: `l4d2web/cli.py:34`
|
|
- Test: `l4d2web/tests/test_auth.py` (append)
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append to `l4d2web/tests/test_auth.py`:
|
|
|
|
```python
|
|
def test_create_user_cli_rejects_short_password(tmp_path, monkeypatch) -> None:
|
|
db_url = f"sqlite:///{tmp_path/'short_pw.db'}"
|
|
monkeypatch.setenv("DATABASE_URL", db_url)
|
|
monkeypatch.setenv("LEFT4ME_ADMIN_PASSWORD", "short7x")
|
|
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
|
init_db()
|
|
|
|
result = app.test_cli_runner().invoke(args=["create-user", "admin", "--admin"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "at least 8" in result.output
|
|
```
|
|
|
|
- [ ] **Step 2: Run — expect failure**
|
|
|
|
Run: `pytest l4d2web/tests/test_auth.py::test_create_user_cli_rejects_short_password -v`
|
|
Expected: FAIL — short password currently accepted.
|
|
|
|
- [ ] **Step 3: Wire validation into `create_user`**
|
|
|
|
Edit `l4d2web/cli.py`. Update the import line:
|
|
|
|
```python
|
|
from l4d2web.auth import hash_password, validate_new_password
|
|
```
|
|
|
|
Replace lines 34-35 (the empty-string check) with:
|
|
|
|
```python
|
|
policy_error = validate_new_password(password)
|
|
if policy_error is not None:
|
|
raise click.ClickException(policy_error)
|
|
```
|
|
|
|
- [ ] **Step 4: Run — expect pass**
|
|
|
|
Run: `pytest l4d2web/tests/test_auth.py -v -k cli`
|
|
Expected: all PASS, including the existing empty-password test (which is now caught by `validate_new_password`).
|
|
|
|
- [ ] **Step 5: Full suite**
|
|
|
|
Run: `pytest l4d2web/tests -q`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add l4d2web/cli.py l4d2web/tests/test_auth.py
|
|
git commit -m "cli: apply min-length password policy in create-user"
|
|
```
|
|
|
|
---
|
|
|
|
## Final verification
|
|
|
|
- [ ] **Run the full test suite**
|
|
|
|
```bash
|
|
pytest l4d2web/tests -q
|
|
```
|
|
|
|
Expected: all PASS.
|
|
|
|
- [ ] **Run a manual smoke**
|
|
|
|
```bash
|
|
rm -f l4d2web.db
|
|
alembic -c l4d2web/alembic.ini upgrade head
|
|
LEFT4ME_ADMIN_PASSWORD='changeme1' flask --app l4d2web.app:create_app create-user smoke --admin
|
|
SECRET_KEY=devdevdevdev flask --app l4d2web.app:create_app run --port 5001
|
|
```
|
|
|
|
Then in a browser:
|
|
|
|
1. Log in as `smoke / changeme1`.
|
|
2. Click the `smoke` username in the header → lands on `/profile`.
|
|
3. Submit with wrong current password → error banner.
|
|
4. Submit with mismatched confirm → error banner.
|
|
5. Submit with 7-character new password → error banner.
|
|
6. Submit with valid form → success banner, still logged in.
|
|
7. Open a second incognito window, log in again as `smoke / <new password>` → success.
|
|
8. Change password again from the first window.
|
|
9. Reload `/dashboard` in the second window → redirected to `/login`.
|
|
|
|
- [ ] **Static checks (if configured)**
|
|
|
|
```bash
|
|
ruff check l4d2web
|
|
```
|
|
|
|
Expected: clean.
|