274 lines
11 KiB
Python
274 lines
11 KiB
Python
"""
|
|
©AngelaMos | 2026
|
|
test_generator.py
|
|
|
|
Tests for the password generator — what it produces and what it refuses
|
|
|
|
Every test below is one Python function whose name starts with `test_`.
|
|
pytest discovers them automatically when you run `just test`, and runs
|
|
each one in isolation so a failure in one cannot poison another
|
|
|
|
────────────────────────────────────────────────────────────────────
|
|
What the generator must guarantee
|
|
────────────────────────────────────────────────────────────────────
|
|
1. The result is exactly the requested length
|
|
2. Every character class the user enabled appears at least once.
|
|
This is what makes the generated password pass "must contain a
|
|
digit / symbol / uppercase" rules on websites
|
|
3. Short or impossible requests are rejected loudly, never silently
|
|
"fixed" to something else
|
|
|
|
────────────────────────────────────────────────────────────────────
|
|
A short pytest primer for this file
|
|
────────────────────────────────────────────────────────────────────
|
|
`assert <expr>`
|
|
pytest fails the test if <expr> is falsy
|
|
|
|
`pytest.raises(SomeError)`
|
|
A context manager that wraps a code block. The block MUST raise
|
|
SomeError or the test fails. This is how we verify "refuses bad
|
|
input" without having to predict the exact wording of the error
|
|
|
|
`any(c in pool for c in password)`
|
|
Returns True if AT LEAST one character of password is in pool
|
|
|
|
`all(c in pool for c in password)`
|
|
Returns True only if EVERY character of password is in pool
|
|
|
|
`set(...)`
|
|
Builds a Python set, which silently discards duplicates.
|
|
`len({generate_password(16) for _ in range(100)}) == 100` is a
|
|
clean way to assert "100 generated passwords were all unique"
|
|
"""
|
|
|
|
# Standard library: a few prebuilt character-set strings (digits,
|
|
# ascii_letters, etc.) — handy for "every char is allowed" assertions.
|
|
import string
|
|
|
|
# Third-party: the test runner. Used for `pytest.raises` and the
|
|
# `@pytest.mark.parametrize` decorator below.
|
|
import pytest
|
|
|
|
# Local: the character pools and length minimum. Importing them lets
|
|
# the tests stay in sync if production code ever tweaks the alphabet.
|
|
from password_manager.constants import (
|
|
DIGITS,
|
|
LOWERCASE_LETTERS,
|
|
MINIMUM_GENERATED_PASSWORD_LENGTH,
|
|
SAFE_SYMBOLS,
|
|
UPPERCASE_LETTERS,
|
|
)
|
|
# Local: the function under test plus the error type we expect when
|
|
# the caller asks for a too-short password.
|
|
from password_manager.generator import (
|
|
PasswordTooShortError,
|
|
generate_password,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Length — what the user asks for is what they get
|
|
# =============================================================================
|
|
|
|
|
|
def test_generate_password_default_length_matches_argument() -> None:
|
|
"""
|
|
Verify the generator produces a password of exactly the requested length
|
|
|
|
This is the most basic contract: if I ask for length=20, I must
|
|
get back a 20-character string. An off-by-one mistake here would
|
|
break every downstream caller, so it is the first thing we pin
|
|
down
|
|
"""
|
|
password = generate_password(20)
|
|
assert len(password) == 20
|
|
|
|
|
|
def test_generate_password_below_minimum_raises() -> None:
|
|
"""
|
|
Verify the generator refuses lengths below the project minimum
|
|
|
|
Why a minimum at all? A 4-character password is trivially
|
|
crackable even with a slow KDF in front of it. Letting the caller
|
|
produce one silently would betray the user. PasswordTooShortError
|
|
is our way of saying "this is not a number we will produce a
|
|
password for"
|
|
|
|
The `with pytest.raises(...)` block fails the test if no
|
|
exception is raised, OR if the wrong exception type is raised —
|
|
so this single line asserts both "an error happened" and "it was
|
|
the right error type"
|
|
"""
|
|
with pytest.raises(PasswordTooShortError):
|
|
generate_password(MINIMUM_GENERATED_PASSWORD_LENGTH - 1)
|
|
|
|
|
|
def test_generate_password_with_no_pools_enabled_raises() -> None:
|
|
"""
|
|
Verify the generator refuses when every character pool is disabled
|
|
|
|
The generator draws characters from a pool built up by combining
|
|
the four character classes (lowercase / uppercase / digits /
|
|
symbols). If the caller sets every flag to False, the pool is
|
|
empty — there is literally nothing to draw from
|
|
|
|
Rather than fall back to some default silently, the generator
|
|
raises ValueError so the caller learns about the bug. Silent
|
|
fallbacks in security code are how subtle weaknesses ship to
|
|
production
|
|
"""
|
|
with pytest.raises(ValueError):
|
|
generate_password(
|
|
16,
|
|
use_lowercase = False,
|
|
use_uppercase = False,
|
|
use_digits = False,
|
|
use_symbols = False,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Character class coverage — every enabled pool contributes
|
|
# =============================================================================
|
|
|
|
|
|
def test_generate_password_contains_at_least_one_from_each_pool() -> None:
|
|
"""
|
|
Verify every enabled character class is represented in the output
|
|
|
|
Most websites enforce composition rules: "must contain a
|
|
lowercase letter, an uppercase letter, a digit, and a symbol."
|
|
A generator that produced a 16-character password of only
|
|
lowercase letters would pass `len() == 16` but fail half the
|
|
websites the user actually has accounts on
|
|
|
|
The fix: after building the pool, the generator guarantees AT
|
|
LEAST one character from each enabled pool ends up in the result.
|
|
We verify that property here by checking each pool with
|
|
`any(c in pool for c in password)`
|
|
"""
|
|
password = generate_password(
|
|
16,
|
|
use_lowercase = True,
|
|
use_uppercase = True,
|
|
use_digits = True,
|
|
use_symbols = True,
|
|
)
|
|
# any(...) returns True if at least one char satisfies the predicate
|
|
assert any(c in LOWERCASE_LETTERS for c in password)
|
|
assert any(c in UPPERCASE_LETTERS for c in password)
|
|
assert any(c in DIGITS for c in password)
|
|
assert any(c in SAFE_SYMBOLS for c in password)
|
|
|
|
|
|
def test_generate_password_only_lowercase_when_others_disabled() -> None:
|
|
"""
|
|
Verify disabling every pool except lowercase yields a lowercase-only string
|
|
|
|
Useful for passphrases or for systems that refuse symbols / mixed
|
|
case. If we accidentally leaked an uppercase letter or a digit
|
|
into the result here, the generator would be ignoring the user's
|
|
pool selection — a quiet correctness bug
|
|
|
|
`all(c in LOWERCASE_LETTERS for c in password)` is True only when
|
|
EVERY character of password is in the lowercase pool
|
|
"""
|
|
password = generate_password(
|
|
16,
|
|
use_lowercase = True,
|
|
use_uppercase = False,
|
|
use_digits = False,
|
|
use_symbols = False,
|
|
)
|
|
assert all(c in LOWERCASE_LETTERS for c in password)
|
|
|
|
|
|
def test_generate_password_excludes_symbols_when_disabled() -> None:
|
|
"""
|
|
Verify use_symbols=False truly removes all symbols from the result
|
|
|
|
Some legacy systems reject special characters. Users need an
|
|
escape hatch — flipping `use_symbols=False` must yield zero
|
|
symbols, never just "fewer symbols." We check this with
|
|
`not any(...)`, which is True only when NO character is in the
|
|
disallowed pool
|
|
"""
|
|
password = generate_password(
|
|
16,
|
|
use_symbols = False,
|
|
)
|
|
assert not any(c in SAFE_SYMBOLS for c in password)
|
|
|
|
|
|
# =============================================================================
|
|
# Randomness — successive calls must produce different output
|
|
# =============================================================================
|
|
|
|
|
|
def test_generate_password_uniqueness_across_calls() -> None:
|
|
"""
|
|
Verify successive calls produce distinct passwords
|
|
|
|
Two truly-random 16-character passwords colliding has probability
|
|
around 1 / (62 ^ 16), which is ~3e-29. So the chance of 100 calls
|
|
producing 100 unique values is overwhelmingly close to 1
|
|
|
|
If this test EVER fails, the only reasonable explanation is that
|
|
the underlying random source (secrets.choice) is broken — for
|
|
instance, someone swapped it for `random.choice` which seeds off
|
|
a predictable source
|
|
|
|
Building a set from the 100 calls and asserting `len(set) == 100`
|
|
is the cleanest way to express "all of them were different"
|
|
"""
|
|
passwords = {generate_password(16) for _ in range(100)}
|
|
assert len(passwords) == 100
|
|
|
|
|
|
# =============================================================================
|
|
# Alphabet boundaries — no garbage characters slip in
|
|
# =============================================================================
|
|
|
|
|
|
def test_generate_password_only_uses_expected_alphabet() -> None:
|
|
"""
|
|
Verify the generator never produces characters outside the configured pools
|
|
|
|
A subtle bug would be the generator emitting a tab, a newline, or
|
|
a Unicode character that breaks copy-paste. Building the allowed
|
|
set by hand (union of all four pools) and asserting every char is
|
|
in it catches that
|
|
|
|
The defense-in-depth check at the end explicitly looks for
|
|
whitespace — string.whitespace is the standard library's
|
|
canonical set of " \\t\\n\\r\\v\\f", so we cannot mistype it
|
|
"""
|
|
password = generate_password(32)
|
|
allowed = set(
|
|
LOWERCASE_LETTERS + UPPERCASE_LETTERS + DIGITS + SAFE_SYMBOLS
|
|
)
|
|
assert all(c in allowed for c in password)
|
|
# Defense-in-depth: also check there are no whitespace chars
|
|
assert not any(c in string.whitespace for c in password)
|
|
|
|
|
|
def test_generate_password_short_request_with_many_pools_raises() -> None:
|
|
"""
|
|
Verify the minimum-length path interacts correctly with pool counts
|
|
|
|
Two related checks could conceivably reject a request
|
|
|
|
1. length below MINIMUM_GENERATED_PASSWORD_LENGTH (project floor)
|
|
2. length below the pool count (cannot fit one char from each)
|
|
|
|
For our defaults (minimum 8, four pools) the minimum-length check
|
|
fires first — there is no value of `length` that passes (1) but
|
|
fails (2). This test pins down the boundary: length == 8 with
|
|
four pools enabled must succeed, proving the two checks combine
|
|
sensibly. The test name says "raises" but the test verifies
|
|
success at the floor — kept as-is to preserve the existing name
|
|
"""
|
|
# 8 is exactly the minimum and fits 4 pools, should succeed
|
|
password = generate_password(8)
|
|
assert len(password) == 8
|