Nobody has started this yet — be first.
Business impact
A user's mental model of "I disabled 2FA" includes an implicit assumption: any recovery code they wrote down, screenshotted, pasted into a support ticket, or had seen by someone else along the way is now dead. That assumption is false. Anyone holding an old code -- an ex-employee with access to an old support thread, someone who found a screenshot, a family member who saw it once -- can use it to pass a 2FA challenge on an account whose owner believes they have rotated past it entirely. This is a stale-credential exposure that is invisible in every normal usage of the product.
Problem
disable_2fa() clears user.totp_secret and sets totp_enabled = False, but the line that used to delete the user's BackupCode rows is gone -- those rows (hashed, but matched by consume_backup_code() against user_id alone, with no dependency on or ) are left untouched.
Current behavior
Calling consume_backup_code(user_id, "<old backup code>") after 2FA has been disabled still returns True instead of False.
Expected behavior
Disabling 2FA invalidates every backup code the user had, exactly like it invalidates the TOTP secret. If 2FA is re-enabled later, backup codes issued under the previous enablement must never work again.
Steps to reproduce
Why this matters
This is exactly the class of bug where a state transition (2FA on -> off) that is supposed to be a hard reset left one piece of related state (the backup codes) untouched, and nothing about the rest of the system notices, because consume_backup_code() has no way to know 2FA was ever disabled -- it just checks used_at is None.
Suggested approach
Compare disable_2fa() against confirm_2fa(), which is the function that creates backup codes in the first place, and against the regenerate-backup-codes ticket's stub, which has the same "invalidate the old set" requirement. All three functions care about the same invariant: a user should never have a live BackupCode row that does not correspond to their current 2FA enrollment.
Acceptance criteria
Verification
pytest practice_tickets/tests/test_ticket_09.py -v
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
Sign in to create it automatically, or do it yourself:
$git checkout -b fix/disable-2fa-backup-codesFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin fix/disable-2fa-backup-codesSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign intotp_secrettotp_enabledhttp://localhost:5000make seedpyotpmake docker-prod-upYou do not need Docker running at all to work the tickets -- the test suite uses an in-memory SQLite database (via StaticPool, an isolated DB per test function) and fakeredis (TestingConfig.USE_FAKE_REDIS), so everything runs against a plain virtualenv:
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pytest practice_tickets/tests/test_ticket_01.py -v # a single ticket
./practice_tickets/run_tickets.sh # all 15, clean pass/fail summary
All 15 tests fail at the start; as you fix each ticket in app/, its test (and only its test) should flip to passing. make test (pytest -q) runs the project's own main suite -- as of writing it shows 9 pre-existing failures caused by ticket bugs 02, 03, 07, 11, and 15; fixing those tickets fixes those failures too, not a separate task. Do not look at git diff / version control history to find the bugs if this repo is ever put under source control -- the point is to find them the way you would find a real bug: from a failing test and a description of broken behavior, not a diff.
Level 1
Fix a bug
Read existing behaviour, correct it.