960 lines
34 KiB
Python
960 lines
34 KiB
Python
"""
|
|
©AngelaMos | 2026
|
|
test_vault.py
|
|
|
|
Tests for the vault module — file format, persistence, entry CRUD
|
|
|
|
These tests DO touch the filesystem (via pytest's `tmp_path` fixture)
|
|
because the vault's whole job is reading and writing files. Every
|
|
fixture cleans up after itself when the test ends, so the suite
|
|
leaves no debris behind
|
|
|
|
────────────────────────────────────────────────────────────────────
|
|
What is being verified, organized by section
|
|
────────────────────────────────────────────────────────────────────
|
|
1. Vault creation — `UnlockedVault.create()` writes a file at the
|
|
given path with the right format, the right permissions, and
|
|
refuses to clobber an existing vault
|
|
|
|
2. Round-trip — save a vault, reopen it, get back exactly what was
|
|
stored. Verify wrong-password, missing-file, malformed-file,
|
|
and out-of-range KDF parameters all raise the right exceptions
|
|
|
|
3. Entry dataclass — Entry is frozen (cannot be mutated after
|
|
construction); from_dict refuses corrupted input loudly instead
|
|
of silently filling in defaults for required fields
|
|
|
|
4. Entry operations — add / get / delete / names. Duplicate
|
|
detection, the `force` overwrite flag, sort order, entry-name
|
|
validation that rejects leading/trailing whitespace
|
|
|
|
5. Master password rotation — change_master_password rotates BOTH
|
|
the salt AND the key. After rotation the old password no longer
|
|
works; the new password unlocks; all entries survive
|
|
|
|
6. Lifecycle — close() and the context-manager `with` block both
|
|
drop the key + entries from memory when done
|
|
|
|
7. Persistence — every save produces a fresh nonce, never leaves a
|
|
stale .tmp file, and uses the secure 0600 file mode from the
|
|
very first syscall
|
|
|
|
────────────────────────────────────────────────────────────────────
|
|
A short pytest primer for this file
|
|
────────────────────────────────────────────────────────────────────
|
|
`tmp_path` (built-in fixture)
|
|
Pytest creates a fresh empty temporary directory for each test
|
|
and passes its Path here. Auto-cleaned at test end
|
|
|
|
`monkeypatch` (built-in fixture)
|
|
Lets you temporarily replace attributes on objects (functions,
|
|
modules, classes) for the duration of one test. We use it to
|
|
spy on `os.open` and to simulate `os.replace` failing. After
|
|
the test ends, the original attributes are restored
|
|
|
|
`@pytest.mark.parametrize("name", [v1, v2, v3])`
|
|
Runs the same test function once per value. So a single
|
|
`test_x(name)` becomes 3 separate tests, each with a different
|
|
name. Cleaner than writing 3 near-identical test functions
|
|
|
|
`fresh_vault` and `master_password` (from conftest.py)
|
|
Fixtures we defined ourselves. Pytest sees them in the test
|
|
function's parameters and wires them in automatically
|
|
"""
|
|
|
|
# Standard library: `dataclasses.replace` lets us copy an Entry with
|
|
# one field changed — handy for building "tampered" inputs.
|
|
import dataclasses
|
|
# Standard library: parse the on-disk vault file so we can assert
|
|
# its structure looks right (versions, key names, etc.).
|
|
import json
|
|
# Standard library: `os.stat` reads filesystem metadata — we use it
|
|
# to check the vault file's permission bits.
|
|
import os
|
|
# Standard library: symbolic names for permission bits (S_IRUSR
|
|
# etc.) — makes the perm check read like English.
|
|
import stat
|
|
# Standard library: object-oriented filesystem paths — type hint
|
|
# for the `tmp_path` fixture and our own helpers.
|
|
from pathlib import Path
|
|
|
|
# Third-party: the test runner. Used for `pytest.raises` and
|
|
# `@pytest.mark.parametrize`.
|
|
import pytest
|
|
|
|
# Local: file-mode and format-version constants. Tests assert
|
|
# against these so the values stay defined in exactly one place.
|
|
from password_manager.constants import (
|
|
VAULT_FILE_MODE,
|
|
VAULT_FORMAT_VERSION,
|
|
)
|
|
# Local: the one crypto error we expect when an unlock attempt
|
|
# uses the wrong master password.
|
|
from password_manager.crypto import WrongPasswordError
|
|
# Local: the full surface of the vault module — entry record,
|
|
# main class, and every error type tests need to raise or catch.
|
|
from password_manager.vault import (
|
|
Entry,
|
|
EntryAlreadyExistsError,
|
|
EntryNotFoundError,
|
|
UnlockedVault,
|
|
VaultAlreadyExistsError,
|
|
VaultFormatError,
|
|
VaultNotFoundError,
|
|
)
|
|
# Local: fast KDF parameters from conftest, so Argon2 work in
|
|
# tests stays in milliseconds.
|
|
from tests.conftest import TEST_KDF_PARAMETERS
|
|
|
|
|
|
# =============================================================================
|
|
# Helpers
|
|
# =============================================================================
|
|
|
|
|
|
def _sample_entry(password: str = "s3cret") -> Entry:
|
|
"""
|
|
Return a fully-populated Entry that tests can drop into a vault
|
|
|
|
Centralizing this means every test that wants "an entry" gets a
|
|
consistent shape with username, password, url, and notes set.
|
|
Tests that care about a specific password override it by passing
|
|
one in
|
|
"""
|
|
return Entry(
|
|
username = "alice",
|
|
password = password,
|
|
url = "https://example.com",
|
|
notes = "primary account",
|
|
)
|
|
|
|
|
|
def _create_test_vault(
|
|
path: Path,
|
|
master_password: str,
|
|
) -> UnlockedVault:
|
|
"""
|
|
Tiny convenience wrapper that always uses TEST_KDF_PARAMETERS
|
|
|
|
The whole point of test fixtures is to keep noise out of test
|
|
bodies — this exists so every test that needs to call create()
|
|
directly can do so without restating the kdf_parameters kwarg
|
|
every time
|
|
"""
|
|
return UnlockedVault.create(
|
|
path,
|
|
master_password,
|
|
kdf_parameters = TEST_KDF_PARAMETERS,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Vault creation
|
|
# =============================================================================
|
|
|
|
|
|
def test_create_writes_file_at_path(
|
|
vault_path: Path,
|
|
master_password: str,
|
|
) -> None:
|
|
"""
|
|
Verify UnlockedVault.create() puts a real file at the path we gave it
|
|
|
|
The most basic post-condition: after `create(path, ...)` returns,
|
|
`path` exists on disk. If a future refactor accidentally returned
|
|
an in-memory-only vault that forgot to save, this test catches
|
|
it before the user loses data on the next process restart
|
|
"""
|
|
_create_test_vault(vault_path, master_password)
|
|
assert vault_path.exists()
|
|
|
|
|
|
def test_create_refuses_to_overwrite_existing_file(
|
|
vault_path: Path,
|
|
master_password: str,
|
|
) -> None:
|
|
"""
|
|
Verify create() refuses to clobber a vault that already exists
|
|
|
|
`pv init` is a destructive-feeling operation: if it silently
|
|
overwrote an existing vault, a user who mistyped a command could
|
|
lose every credential they had stored. Refusing with
|
|
VaultAlreadyExistsError forces the user to make a conscious
|
|
choice (delete the old file first if they really mean to start
|
|
over)
|
|
"""
|
|
_create_test_vault(vault_path, master_password)
|
|
with pytest.raises(VaultAlreadyExistsError):
|
|
_create_test_vault(vault_path, master_password)
|
|
|
|
|
|
def test_create_sets_file_mode_to_0600(
|
|
vault_path: Path,
|
|
master_password: str,
|
|
) -> None:
|
|
"""
|
|
Verify the vault file is created with restrictive POSIX permissions
|
|
|
|
0600 means "the owner can read/write, nobody else can read OR
|
|
write." Even though the contents are encrypted, broadcasting
|
|
metadata (file size, timestamps, even that the file exists at
|
|
all) to other local users is sloppy. Pin permissions tight at
|
|
creation time
|
|
|
|
`stat.S_IMODE` masks off file-type bits, leaving just the
|
|
permission bits we care about. On Windows POSIX modes do not
|
|
apply at all, so we skip the assertion there
|
|
"""
|
|
_create_test_vault(vault_path, master_password)
|
|
mode = stat.S_IMODE(os.stat(vault_path).st_mode)
|
|
# On Windows, POSIX modes do not apply. Skip that platform's check
|
|
if os.name != "nt":
|
|
assert mode == VAULT_FILE_MODE
|
|
|
|
|
|
def test_create_writes_valid_envelope_json(
|
|
vault_path: Path,
|
|
master_password: str,
|
|
) -> None:
|
|
"""
|
|
Verify the file create() writes is a valid JSON envelope with the expected fields
|
|
|
|
We do not crack open the encrypted payload here — that is
|
|
`unlock`'s job. We just confirm the OUTER envelope has the right
|
|
shape: version field, kdf section naming argon2id, cipher
|
|
section naming aes-256-gcm. If this drifts we lose forward
|
|
compatibility with existing vaults
|
|
"""
|
|
_create_test_vault(vault_path, master_password)
|
|
envelope = json.loads(vault_path.read_text(encoding = "utf-8"))
|
|
assert envelope["version"] == VAULT_FORMAT_VERSION
|
|
assert envelope["kdf"]["name"] == "argon2id"
|
|
assert envelope["cipher"]["name"] == "aes-256-gcm"
|
|
|
|
|
|
def test_create_makes_parent_directory_if_missing(
|
|
tmp_path: Path,
|
|
master_password: str,
|
|
) -> None:
|
|
"""
|
|
Verify create() auto-creates intermediate directories on the way to the vault path
|
|
|
|
Users specify vault paths like `~/.config/pv/vault.json` where
|
|
`~/.config/pv` may not exist yet. We make the parent tree on
|
|
their behalf (with `parents=True, exist_ok=True`) so they do not
|
|
have to mkdir first. This test pins down that convenience
|
|
"""
|
|
nested = tmp_path / "deep" / "nested" / "dir" / "vault.json"
|
|
_create_test_vault(nested, master_password)
|
|
assert nested.exists()
|
|
|
|
|
|
def test_create_rejects_empty_master_password(
|
|
vault_path: Path,
|
|
) -> None:
|
|
"""
|
|
Verify create() refuses an empty master password
|
|
|
|
Empty master password = no real lock on the vault. derive_key
|
|
refuses internally (see test_crypto.py for the underlying
|
|
check), and create() lets that ValueError bubble up unchanged.
|
|
We confirm here that the rejection makes it all the way out
|
|
"""
|
|
with pytest.raises(ValueError):
|
|
_create_test_vault(vault_path, "")
|
|
|
|
|
|
# =============================================================================
|
|
# Round-trip — create, save, unlock, read
|
|
# =============================================================================
|
|
|
|
|
|
def test_unlock_reads_back_what_was_saved(
|
|
fresh_vault: UnlockedVault,
|
|
master_password: str,
|
|
) -> None:
|
|
"""
|
|
Verify the canonical save → unlock round-trip preserves entry data
|
|
|
|
This is THE end-to-end happy-path test: add an entry, save,
|
|
reopen with the same password, every field of the entry
|
|
survives. If this test ever fails the password manager is
|
|
fundamentally broken
|
|
"""
|
|
fresh_vault.add_entry("github", _sample_entry())
|
|
fresh_vault.save()
|
|
|
|
reopened = UnlockedVault.unlock(fresh_vault.path, master_password)
|
|
assert "github" in reopened.entries
|
|
assert reopened.entries["github"].username == "alice"
|
|
assert reopened.entries["github"].password == "s3cret"
|
|
|
|
|
|
def test_unlock_with_wrong_password_raises(
|
|
fresh_vault: UnlockedVault,
|
|
) -> None:
|
|
"""
|
|
Verify unlock() raises WrongPasswordError when the master password is wrong
|
|
|
|
The primary security guarantee of the whole project. We create a
|
|
vault with one password, attempt to unlock with a different
|
|
password, and expect a clean WrongPasswordError. NOT a garbled
|
|
plaintext, NOT a silent empty vault, NOT a crypto-library
|
|
InvalidTag exception leaking out — a clean named error
|
|
"""
|
|
with pytest.raises(WrongPasswordError):
|
|
UnlockedVault.unlock(fresh_vault.path, "not the right password")
|
|
|
|
|
|
def test_unlock_missing_file_raises(tmp_path: Path) -> None:
|
|
"""
|
|
Verify unlock() on a nonexistent file raises VaultNotFoundError
|
|
|
|
Distinct from "wrong password" so the CLI can show a different
|
|
message ("no vault at this path" vs "wrong password"). This is
|
|
the only file-related error that does NOT need to be ambiguous
|
|
with the wrong-password case — confirming "this file does not
|
|
exist" is safe information to give attackers
|
|
"""
|
|
with pytest.raises(VaultNotFoundError):
|
|
UnlockedVault.unlock(tmp_path / "nope.json", "any-password")
|
|
|
|
|
|
def test_unlock_invalid_json_raises(
|
|
vault_path: Path,
|
|
) -> None:
|
|
"""
|
|
Verify unlock() raises VaultFormatError when the file is not valid JSON
|
|
|
|
Someone hand-edited the vault, or a disk error corrupted it. We
|
|
cannot proceed but we also should not show a Python traceback —
|
|
catch json.JSONDecodeError internally and re-raise as our own
|
|
typed error
|
|
"""
|
|
vault_path.write_text("this is not json")
|
|
with pytest.raises(VaultFormatError):
|
|
UnlockedVault.unlock(vault_path, "any-password")
|
|
|
|
|
|
def test_unlock_unsupported_version_raises(
|
|
vault_path: Path,
|
|
) -> None:
|
|
"""
|
|
Verify unlock() refuses a vault with an unknown format version
|
|
|
|
Future-proofing. If a future build of pv writes vaults with
|
|
`version: 2`, today's build needs to refuse cleanly (rather than
|
|
try to parse a v2 file with v1 rules and fail mysteriously).
|
|
`VaultFormatError` is the contract — the CLI can render it as
|
|
"this vault was created by a newer version of pv"
|
|
"""
|
|
vault_path.write_text(
|
|
json.dumps({
|
|
"version": 99,
|
|
"kdf": {},
|
|
"cipher": {}
|
|
})
|
|
)
|
|
with pytest.raises(VaultFormatError):
|
|
UnlockedVault.unlock(vault_path, "any-password")
|
|
|
|
|
|
def test_unlock_rejects_zero_time_cost(
|
|
vault_path: Path,
|
|
) -> None:
|
|
"""
|
|
Verify a corrupted vault with time_cost=0 surfaces as VaultFormatError
|
|
|
|
Without parameter validation, time_cost=0 would crash deep
|
|
inside argon2-cffi with a confusing low-level message. Catching
|
|
invalid KDF parameters at parse time means the user sees a clean
|
|
"invalid vault" message and the cryptography library never gets
|
|
handed garbage
|
|
|
|
The base64 fields below (`AAAA...==`) are dummy placeholders —
|
|
we never get far enough to use them because the parameter check
|
|
fires first
|
|
"""
|
|
vault_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"kdf": {
|
|
"name": "argon2id",
|
|
"salt": "AAAAAAAAAAAAAAAAAAAAAA==",
|
|
"time_cost": 0,
|
|
"memory_cost": 8,
|
|
"parallelism": 1,
|
|
},
|
|
"cipher": {
|
|
"name": "aes-256-gcm",
|
|
"nonce": "AAAAAAAAAAAAAAAA",
|
|
"ciphertext": "AAAAAAAAAAAAAAAA",
|
|
},
|
|
}
|
|
)
|
|
)
|
|
with pytest.raises(VaultFormatError):
|
|
UnlockedVault.unlock(vault_path, "any-password")
|
|
|
|
|
|
def test_unlock_rejects_memory_cost_below_lane_floor(
|
|
vault_path: Path,
|
|
) -> None:
|
|
"""
|
|
Verify a vault that violates Argon2's "memory_cost >= 8 * parallelism" rule is rejected
|
|
|
|
Argon2 algorithmically requires memory_cost >= 8 * parallelism.
|
|
A vault file that violates this invariant cannot decrypt; we
|
|
surface that as VaultFormatError BEFORE handing the parameters
|
|
to argon2-cffi. Same reasoning as the time_cost=0 test: validate
|
|
at the boundary so internal libraries never see bad input
|
|
"""
|
|
vault_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"kdf": {
|
|
"name": "argon2id",
|
|
"salt": "AAAAAAAAAAAAAAAAAAAAAA==",
|
|
"time_cost": 1,
|
|
"memory_cost": 4,
|
|
"parallelism": 2,
|
|
},
|
|
"cipher": {
|
|
"name": "aes-256-gcm",
|
|
"nonce": "AAAAAAAAAAAAAAAA",
|
|
"ciphertext": "AAAAAAAAAAAAAAAA",
|
|
},
|
|
}
|
|
)
|
|
)
|
|
with pytest.raises(VaultFormatError):
|
|
UnlockedVault.unlock(vault_path, "any-password")
|
|
|
|
|
|
# =============================================================================
|
|
# Entry — frozen + strict from_dict
|
|
# =============================================================================
|
|
|
|
|
|
def test_entry_is_immutable() -> None:
|
|
"""
|
|
Verify Entry instances reject attribute assignment after construction
|
|
|
|
Frozen dataclass instances raise FrozenInstanceError on any
|
|
attempted attribute write. The whole point is to force every
|
|
"edit" through `UnlockedVault.add_entry`, which is the ONLY
|
|
method that knows how to update the `updated_at` timestamp
|
|
correctly. Making the wrong move impossible at the type level
|
|
beats writing a comment saying "do not do that"
|
|
|
|
`# type: ignore[misc]` tells mypy "yes I know this is illegal,
|
|
that is the point of the test"
|
|
"""
|
|
entry = Entry(username = "alice", password = "x")
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
entry.password = "y" # type: ignore[misc]
|
|
|
|
|
|
def test_entry_from_dict_missing_password_raises() -> None:
|
|
"""
|
|
Verify Entry.from_dict refuses a dict without a `password` field
|
|
|
|
The required fields are username and password. If a vault on
|
|
disk has been corrupted or hand-edited so an entry is missing
|
|
its password, the right answer is to refuse the whole load loud
|
|
enough that the user notices — NOT to invent an empty password
|
|
and pretend everything is fine
|
|
"""
|
|
with pytest.raises(VaultFormatError):
|
|
Entry.from_dict({"username": "alice"})
|
|
|
|
|
|
def test_entry_from_dict_missing_username_raises() -> None:
|
|
"""
|
|
Verify Entry.from_dict refuses a dict without a `username` field
|
|
|
|
Mirror of the missing-password test. Required fields cannot be
|
|
invented from defaults; the load must fail loudly
|
|
"""
|
|
with pytest.raises(VaultFormatError):
|
|
Entry.from_dict({"password": "x"})
|
|
|
|
|
|
def test_entry_from_dict_non_string_password_raises() -> None:
|
|
"""
|
|
Verify Entry.from_dict refuses a non-string password
|
|
|
|
JSON allows numbers, booleans, nulls, and nested objects in any
|
|
field. If somebody hand-edited the decrypted vault to put 12345
|
|
(an int) in the password field, we must refuse rather than try
|
|
to use the int as a password. VaultFormatError is the contract
|
|
"""
|
|
with pytest.raises(VaultFormatError):
|
|
Entry.from_dict({"username": "alice", "password": 12345})
|
|
|
|
|
|
def test_entry_from_dict_uses_empty_string_for_missing_timestamps(
|
|
) -> None:
|
|
"""
|
|
Verify Entry.from_dict defaults missing timestamps to "" rather than _now_iso()
|
|
|
|
The two non-required fields, `created_at` and `updated_at`,
|
|
default to empty strings when absent. Inventing a current
|
|
timestamp on read would make an OLD entry look freshly created,
|
|
which is actively misleading — better to admit "we do not know
|
|
when this was created" with an empty string
|
|
"""
|
|
entry = Entry.from_dict({"username": "alice", "password": "x"})
|
|
assert entry.created_at == ""
|
|
assert entry.updated_at == ""
|
|
|
|
|
|
# =============================================================================
|
|
# Entry operations
|
|
# =============================================================================
|
|
|
|
|
|
def test_add_entry_appears_in_names(fresh_vault: UnlockedVault) -> None:
|
|
"""
|
|
Verify an added entry shows up in names()
|
|
|
|
The minimum contract for `add_entry`: after calling it, the
|
|
entry name appears in the names() list. If this ever fails, the
|
|
add path is silently dropping data
|
|
"""
|
|
fresh_vault.add_entry("github", _sample_entry())
|
|
assert "github" in fresh_vault.names()
|
|
|
|
|
|
def test_names_returns_sorted(fresh_vault: UnlockedVault) -> None:
|
|
"""
|
|
Verify names() returns entries in alphabetical order, not insertion order
|
|
|
|
Users grep / scan a list of names; alphabetical is what they
|
|
expect. We add entries in scrambled order (zebra, apple, mango)
|
|
and confirm names() yields them in alphabetical order. If we
|
|
accidentally returned insertion order, this test would catch it
|
|
"""
|
|
fresh_vault.add_entry("zebra", _sample_entry())
|
|
fresh_vault.add_entry("apple", _sample_entry())
|
|
fresh_vault.add_entry("mango", _sample_entry())
|
|
assert fresh_vault.names() == ["apple", "mango", "zebra"]
|
|
|
|
|
|
def test_add_entry_refuses_duplicate(fresh_vault: UnlockedVault) -> None:
|
|
"""
|
|
Verify add_entry refuses a duplicate name when force is False
|
|
|
|
Default behavior: do not let the user accidentally overwrite an
|
|
existing entry. They must pass `force=True` explicitly to
|
|
overwrite. EntryAlreadyExistsError is the cleanly-typed signal
|
|
so the CLI can prompt the user with "did you mean to overwrite?"
|
|
"""
|
|
fresh_vault.add_entry("github", _sample_entry())
|
|
with pytest.raises(EntryAlreadyExistsError):
|
|
fresh_vault.add_entry("github", _sample_entry())
|
|
|
|
|
|
def test_add_entry_with_force_overwrites(
|
|
fresh_vault: UnlockedVault,
|
|
) -> None:
|
|
"""
|
|
Verify add_entry with force=True replaces an existing entry
|
|
|
|
The intentional-overwrite path. After a force-replace, the
|
|
entry's data should reflect the NEW values, not the old. We
|
|
confirm the password specifically because that is the field
|
|
users rotate most often
|
|
"""
|
|
fresh_vault.add_entry("github", _sample_entry(password = "old"))
|
|
fresh_vault.add_entry(
|
|
"github",
|
|
_sample_entry(password = "new"),
|
|
force = True,
|
|
)
|
|
assert fresh_vault.get_entry("github").password == "new"
|
|
|
|
|
|
def test_overwrite_preserves_created_at(
|
|
fresh_vault: UnlockedVault,
|
|
) -> None:
|
|
"""
|
|
Verify a force-overwrite preserves the original created_at timestamp
|
|
|
|
When a user rotates a password, the entry's CREATION date should
|
|
not change — only the LAST-UPDATED date. Otherwise the user
|
|
cannot answer "how old is this credential" after a rotation.
|
|
This invariant is a small detail but it is the kind of polish
|
|
that distinguishes a real tool from a homework assignment
|
|
"""
|
|
fresh_vault.add_entry("github", _sample_entry())
|
|
original_created = fresh_vault.get_entry("github").created_at
|
|
|
|
fresh_vault.add_entry(
|
|
"github",
|
|
_sample_entry(password = "rotated"),
|
|
force = True,
|
|
)
|
|
|
|
assert fresh_vault.get_entry("github").created_at == original_created
|
|
|
|
|
|
def test_get_entry_missing_raises(fresh_vault: UnlockedVault) -> None:
|
|
"""
|
|
Verify get_entry on a name that does not exist raises EntryNotFoundError
|
|
|
|
Returning None on miss would be a quieter API — but quiet
|
|
failures make for hard-to-debug bugs. EntryNotFoundError is
|
|
impossible to ignore: either the caller catches it and handles
|
|
the miss, or it propagates and the CLI shows a clean error
|
|
"""
|
|
with pytest.raises(EntryNotFoundError):
|
|
fresh_vault.get_entry("does-not-exist")
|
|
|
|
|
|
def test_delete_entry_removes_it(fresh_vault: UnlockedVault) -> None:
|
|
"""
|
|
Verify delete_entry actually removes the entry from names()
|
|
|
|
Round-trip: add, delete, confirm it is gone. If delete were
|
|
accidentally a no-op, this test catches it before the user finds
|
|
out the hard way
|
|
"""
|
|
fresh_vault.add_entry("github", _sample_entry())
|
|
fresh_vault.delete_entry("github")
|
|
assert "github" not in fresh_vault.names()
|
|
|
|
|
|
def test_delete_entry_missing_raises(
|
|
fresh_vault: UnlockedVault,
|
|
) -> None:
|
|
"""
|
|
Verify delete_entry on a name that does not exist raises EntryNotFoundError
|
|
|
|
Mirror of the get_entry missing test. We do not silently succeed
|
|
on a delete that had nothing to delete — that is the kind of
|
|
forgiving behavior that masks real bugs in calling code
|
|
"""
|
|
with pytest.raises(EntryNotFoundError):
|
|
fresh_vault.delete_entry("does-not-exist")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad_name",
|
|
["",
|
|
" ",
|
|
"\t",
|
|
"\n",
|
|
" github",
|
|
"github ",
|
|
" github "],
|
|
)
|
|
def test_add_entry_rejects_invalid_names(
|
|
fresh_vault: UnlockedVault,
|
|
bad_name: str,
|
|
) -> None:
|
|
"""
|
|
Verify add_entry rejects empty, whitespace-only, or whitespace-surrounded names
|
|
|
|
`@pytest.mark.parametrize` runs this test ONCE PER VALUE in the
|
|
list — so pytest reports 7 separate test results, one per bad
|
|
name. Cleaner than seven near-identical test functions
|
|
|
|
The trailing-whitespace case is the subtle one: without this
|
|
check, "github" and "github " would silently become two
|
|
different keys, which looks identical on screen and wastes
|
|
hours of debugging time
|
|
"""
|
|
with pytest.raises(ValueError):
|
|
fresh_vault.add_entry(bad_name, _sample_entry())
|
|
|
|
|
|
# =============================================================================
|
|
# Master password rotation
|
|
# =============================================================================
|
|
|
|
|
|
def test_change_master_password_rotates_key_and_salt(
|
|
fresh_vault: UnlockedVault,
|
|
master_password: str,
|
|
) -> None:
|
|
"""
|
|
Verify change_master_password generates a fresh salt AND a fresh key
|
|
|
|
Rotation is not just "encrypt with a new password" — it is
|
|
"generate a NEW salt, derive a NEW key from (new password +
|
|
new salt), re-encrypt." Reusing the old salt would let an
|
|
attacker who got both versions of the file run their guesses
|
|
twice as cheaply. Fresh salt → independent key → independent
|
|
cracking work for each
|
|
"""
|
|
fresh_vault.add_entry("github", _sample_entry())
|
|
original_salt = fresh_vault.salt
|
|
original_key = fresh_vault.key
|
|
|
|
fresh_vault.change_master_password(
|
|
"an entirely new master pass",
|
|
kdf_parameters = TEST_KDF_PARAMETERS,
|
|
)
|
|
fresh_vault.save()
|
|
|
|
assert fresh_vault.salt != original_salt
|
|
assert fresh_vault.key != original_key
|
|
|
|
|
|
def test_change_master_password_old_password_no_longer_unlocks(
|
|
fresh_vault: UnlockedVault,
|
|
master_password: str,
|
|
) -> None:
|
|
"""
|
|
Verify the previous master password stops working after rotation
|
|
|
|
The whole point of rotation is "if the old password leaked,
|
|
rotating to a new one kills the leak." We confirm the old
|
|
password is dead by attempting an unlock with it and expecting
|
|
WrongPasswordError
|
|
"""
|
|
fresh_vault.add_entry("github", _sample_entry())
|
|
fresh_vault.change_master_password(
|
|
"the new one",
|
|
kdf_parameters = TEST_KDF_PARAMETERS,
|
|
)
|
|
fresh_vault.save()
|
|
|
|
with pytest.raises(WrongPasswordError):
|
|
UnlockedVault.unlock(fresh_vault.path, master_password)
|
|
|
|
|
|
def test_change_master_password_new_password_unlocks_with_entries_intact(
|
|
fresh_vault: UnlockedVault,
|
|
) -> None:
|
|
"""
|
|
Verify the new master password unlocks the vault AND all entries are preserved
|
|
|
|
The other half of the rotation contract: the user keeps their
|
|
data. Reopen with the new password, every entry that was there
|
|
before is still there with the same password field
|
|
"""
|
|
fresh_vault.add_entry("github", _sample_entry())
|
|
fresh_vault.change_master_password(
|
|
"the new one",
|
|
kdf_parameters = TEST_KDF_PARAMETERS,
|
|
)
|
|
fresh_vault.save()
|
|
|
|
reopened = UnlockedVault.unlock(fresh_vault.path, "the new one")
|
|
assert reopened.entries["github"].password == "s3cret"
|
|
|
|
|
|
def test_change_master_password_rejects_empty(
|
|
fresh_vault: UnlockedVault,
|
|
) -> None:
|
|
"""
|
|
Verify change_master_password refuses an empty new password
|
|
|
|
The same "empty password = no real lock" rule that applies to
|
|
creation also applies to rotation. We refuse before generating
|
|
a salt — both because there is no reason to generate one we
|
|
will throw away, and because raising sooner gives a cleaner
|
|
error
|
|
"""
|
|
with pytest.raises(ValueError):
|
|
fresh_vault.change_master_password("")
|
|
|
|
|
|
# =============================================================================
|
|
# Lifecycle — context manager + close()
|
|
# =============================================================================
|
|
|
|
|
|
def test_close_zeroes_key_and_drops_entries(
|
|
fresh_vault: UnlockedVault,
|
|
) -> None:
|
|
"""
|
|
Verify close() wipes the in-memory secrets
|
|
|
|
After `close()`, the AES key field becomes 32 zero bytes and
|
|
the entries dict becomes empty. This is best-effort — Python's
|
|
bytes are immutable so the ORIGINAL key bytes may still live in
|
|
memory until GC runs — but the discipline of "explicitly drop
|
|
secrets when done" matters. A test that pins the post-close
|
|
state guards against accidental refactoring that skips the wipe
|
|
"""
|
|
fresh_vault.add_entry("github", _sample_entry())
|
|
fresh_vault.close()
|
|
assert fresh_vault.entries == {}
|
|
assert fresh_vault.key == bytes(32)
|
|
|
|
|
|
def test_unlocked_vault_works_as_context_manager(
|
|
vault_path: Path,
|
|
master_password: str,
|
|
) -> None:
|
|
"""
|
|
Verify `with UnlockedVault.create(...) as vault:` triggers close() on exit
|
|
|
|
The recommended call site pattern is the with-block, because it
|
|
guarantees secrets get dropped even if the caller forgets to
|
|
call close(). We confirm here that exiting the `with` block
|
|
actually triggers the close behavior (empty entries, zeroed key)
|
|
"""
|
|
with _create_test_vault(vault_path, master_password) as vault:
|
|
vault.add_entry("github", _sample_entry())
|
|
assert vault.entries["github"].username == "alice"
|
|
# On block exit, sensitive material is dropped
|
|
assert vault.entries == {}
|
|
assert vault.key == bytes(32)
|
|
|
|
|
|
def test_context_manager_cleans_up_on_exception(
|
|
vault_path: Path,
|
|
master_password: str,
|
|
) -> None:
|
|
"""
|
|
Verify the context manager wipes secrets even when the body raises
|
|
|
|
The HAPPY path is easy — close() runs at normal end-of-block.
|
|
The interesting case is the EXCEPTION path: if user code inside
|
|
the `with` block raises, does Python still invoke __exit__? Yes
|
|
it does, and we confirm here that __exit__ drops secrets all
|
|
the same. Without this guarantee, an unexpected error in user
|
|
code could leave plaintext credentials sitting in memory
|
|
"""
|
|
with (
|
|
pytest.raises(RuntimeError),
|
|
_create_test_vault(vault_path,
|
|
master_password) as vault,
|
|
):
|
|
vault.add_entry("github", _sample_entry())
|
|
raise RuntimeError("boom")
|
|
assert vault.entries == {}
|
|
assert vault.key == bytes(32)
|
|
|
|
|
|
# =============================================================================
|
|
# Atomicity / persistence
|
|
# =============================================================================
|
|
|
|
|
|
def test_save_uses_fresh_nonce_each_time(
|
|
fresh_vault: UnlockedVault,
|
|
) -> None:
|
|
"""
|
|
Verify two consecutive saves produce different ciphertext (because the nonce is fresh)
|
|
|
|
The save path internally regenerates a nonce on every call.
|
|
Save once → record the nonce + ciphertext. Save again with NO
|
|
entry change → record again. Both fields must differ. If they
|
|
did not, we would either be reusing a nonce (security bug) or
|
|
using a counter (different security bug)
|
|
"""
|
|
fresh_vault.save()
|
|
cipher_a = json.loads(fresh_vault.path.read_text())["cipher"]
|
|
fresh_vault.save()
|
|
cipher_b = json.loads(fresh_vault.path.read_text())["cipher"]
|
|
|
|
assert cipher_a["nonce"] != cipher_b["nonce"]
|
|
assert cipher_a["ciphertext"] != cipher_b["ciphertext"]
|
|
|
|
|
|
def test_save_does_not_leave_temp_file(
|
|
fresh_vault: UnlockedVault,
|
|
) -> None:
|
|
"""
|
|
Verify the .tmp file used during save is gone after save() returns
|
|
|
|
The save flow writes vault.json.tmp first, then renames it onto
|
|
vault.json. After the rename, the .tmp name no longer exists —
|
|
the same inode is now reachable under the final name. We
|
|
confirm by stat-ing the .tmp path after a successful save and
|
|
expecting "does not exist." If anyone refactored save() to copy
|
|
instead of rename, the leftover .tmp would fail this test
|
|
"""
|
|
fresh_vault.save()
|
|
tmp = fresh_vault.path.with_suffix(fresh_vault.path.suffix + ".tmp")
|
|
assert not tmp.exists()
|
|
|
|
|
|
def test_save_creates_temp_file_with_secure_mode_only(
|
|
fresh_vault: UnlockedVault,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Verify the .tmp file is created with mode 0600 from the very first syscall
|
|
|
|
Plain `Path.write_bytes` would create the file with the
|
|
process's default umask (often 0644 = world-readable). Even if
|
|
we chmod 0600 afterward, there is a microsecond window where
|
|
the .tmp is world-readable, and on a shared system that is
|
|
enough for an attacker to grab a copy
|
|
|
|
The fix in vault.py is to use raw `os.open(..., O_CREAT, 0o600)`
|
|
so the file's mode is correct from the first syscall. To VERIFY
|
|
that behavior in a test we use `monkeypatch.setattr` to swap in
|
|
a spy version of `os.open` that records the mode argument used
|
|
for any .tmp file. After save() runs, we assert every recorded
|
|
mode is exactly VAULT_FILE_MODE (0o600)
|
|
|
|
`monkeypatch` is a built-in pytest fixture that undoes its
|
|
patches automatically when the test ends — no manual cleanup
|
|
"""
|
|
if os.name == "nt":
|
|
pytest.skip("POSIX-only check — Windows ignores Unix file modes")
|
|
|
|
captured_modes: list[int] = []
|
|
real_open = os.open
|
|
|
|
def spy_open( # type: ignore[no-untyped-def]
|
|
path,
|
|
flags,
|
|
mode = 0o777,
|
|
*,
|
|
dir_fd = None,
|
|
):
|
|
# Record the mode used for .tmp files, then defer to the real
|
|
# os.open so the actual write still happens normally
|
|
if str(path).endswith(".tmp"):
|
|
captured_modes.append(mode)
|
|
return real_open(path, flags, mode, dir_fd = dir_fd)
|
|
|
|
monkeypatch.setattr(os, "open", spy_open)
|
|
fresh_vault.save()
|
|
|
|
assert captured_modes, "no .tmp file was opened during save()"
|
|
assert all(m == VAULT_FILE_MODE for m in captured_modes)
|
|
|
|
|
|
def test_save_cleans_up_temp_file_on_replace_failure(
|
|
fresh_vault: UnlockedVault,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Verify save() removes the .tmp file when os.replace fails partway through
|
|
|
|
Simulated failure mode: we monkey-patch `os.replace` to raise
|
|
OSError. The save flow is "write .tmp, replace .tmp onto
|
|
vault.json." If the replace step blows up, the .tmp is still
|
|
sitting there — left behind it would clutter the directory and
|
|
confuse the NEXT save (which would then try to write to a
|
|
.tmp that already exists). The cleanup in save()'s except
|
|
branch is what we are verifying here
|
|
|
|
We save once cleanly first so vault.json itself exists, then
|
|
install the broken replace, then save() again and confirm
|
|
the .tmp does not survive the failed save
|
|
"""
|
|
def explode(*_args: object, **_kwargs: object) -> None:
|
|
raise OSError("simulated replace failure")
|
|
|
|
# Save once cleanly so the vault file exists
|
|
fresh_vault.save()
|
|
|
|
monkeypatch.setattr(os, "replace", explode)
|
|
with pytest.raises(OSError, match = "simulated replace failure"):
|
|
fresh_vault.save()
|
|
|
|
tmp = fresh_vault.path.with_suffix(fresh_vault.path.suffix + ".tmp")
|
|
assert not tmp.exists()
|