generated from coulomb/repo-seed
feat(local-identity): implement Stage 1 — core file store (NK-WP-0002-T01)
Deliverables: - src/local_identity/gecos.py: /etc/passwd GECOS parsing, current_username() - src/local_identity/user.py: UserRecord dataclass, ProductionIdentity, make_test_user() - Pure test-user derivation: <user>N / +testN email alias / source_user tracking - src/local_identity/store.py: file store CRUD backed by LOCAL_IDENTITY_HOME - ~/.local-identity/ mode 700, user files mode 600 - All path lookups dynamic (env-var override enables clean test isolation) - src/local_identity/cli.py: init/list/show commands; email from flag > config > prompt - pyproject.toml + uv.lock: pyyaml dep, local-identity script entry point Tests (41 passing): - test_gecos.py: 9 tests — simple/comma/empty/non-ASCII/whitespace GECOS, fallback - test_user.py: 14 tests — test-user derivation, YAML roundtrip, non-ASCII, idempotency - test_store.py: 18 tests — dir creation, permissions (700/600), CRUD, list, config, idempotency (reinit with --force produces identical users) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
19
local-identity/tests/conftest.py
Normal file
19
local-identity/tests/conftest.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Shared pytest fixtures for local-identity tests.
|
||||
|
||||
The LOCAL_IDENTITY_HOME env var redirects the store to a temp directory,
|
||||
keeping tests isolated from the real ~/.local-identity store.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_store(tmp_path, monkeypatch):
|
||||
"""
|
||||
Redirect the local-identity store to a fresh temp directory.
|
||||
Returns the Path to the store root (not yet created — call init_dirs() as needed).
|
||||
"""
|
||||
store_path = tmp_path / ".local-identity"
|
||||
monkeypatch.setenv("LOCAL_IDENTITY_HOME", str(store_path))
|
||||
return store_path
|
||||
55
local-identity/tests/test_gecos.py
Normal file
55
local-identity/tests/test_gecos.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Tests for GECOS / passwd parsing."""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from local_identity.gecos import get_gecos_fullname, current_username
|
||||
|
||||
|
||||
def _mock_entry(gecos: str) -> MagicMock:
|
||||
entry = MagicMock()
|
||||
entry.pw_gecos = gecos
|
||||
return entry
|
||||
|
||||
|
||||
class TestGetGecosFullname:
|
||||
def test_simple_name(self):
|
||||
with patch("pwd.getpwnam", return_value=_mock_entry("Bernd Worsch")):
|
||||
assert get_gecos_fullname("tegwick") == "Bernd Worsch"
|
||||
|
||||
def test_name_with_comma_fields(self):
|
||||
with patch("pwd.getpwnam", return_value=_mock_entry("Bernd Worsch,Room 42,+49-555-1234")):
|
||||
assert get_gecos_fullname("tegwick") == "Bernd Worsch"
|
||||
|
||||
def test_empty_gecos_falls_back_to_username(self):
|
||||
with patch("pwd.getpwnam", return_value=_mock_entry("")):
|
||||
assert get_gecos_fullname("tegwick") == "tegwick"
|
||||
|
||||
def test_only_commas(self):
|
||||
with patch("pwd.getpwnam", return_value=_mock_entry(",,,,")):
|
||||
assert get_gecos_fullname("tegwick") == "tegwick"
|
||||
|
||||
def test_whitespace_stripped(self):
|
||||
with patch("pwd.getpwnam", return_value=_mock_entry(" Bernd Worsch ,,")):
|
||||
assert get_gecos_fullname("tegwick") == "Bernd Worsch"
|
||||
|
||||
def test_non_ascii_name(self):
|
||||
with patch("pwd.getpwnam", return_value=_mock_entry("Ärger Müller,,")):
|
||||
assert get_gecos_fullname("amueller") == "Ärger Müller"
|
||||
|
||||
def test_user_not_in_passwd(self):
|
||||
with patch("pwd.getpwnam", side_effect=KeyError("nobody")):
|
||||
assert get_gecos_fullname("nobody") == "nobody"
|
||||
|
||||
|
||||
class TestCurrentUsername:
|
||||
def test_reads_user_env_var(self, monkeypatch):
|
||||
monkeypatch.setenv("USER", "tegwick")
|
||||
monkeypatch.delenv("LOGNAME", raising=False)
|
||||
assert current_username() == "tegwick"
|
||||
|
||||
def test_falls_back_to_logname(self, monkeypatch):
|
||||
monkeypatch.delenv("USER", raising=False)
|
||||
monkeypatch.setenv("LOGNAME", "tegwick")
|
||||
assert current_username() == "tegwick"
|
||||
145
local-identity/tests/test_store.py
Normal file
145
local-identity/tests/test_store.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Tests for the file store (read/write users, permissions, config)."""
|
||||
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from local_identity.store import (
|
||||
init_dirs,
|
||||
store_exists,
|
||||
write_user,
|
||||
read_user,
|
||||
list_users,
|
||||
read_config,
|
||||
write_config,
|
||||
_users_dir,
|
||||
_store_dir,
|
||||
)
|
||||
from local_identity.user import UserRecord, make_test_user
|
||||
|
||||
ALICE = UserRecord(username="alice", fullname="Alice Smith", email="alice@example.com")
|
||||
BOB = UserRecord(username="bob", fullname="Bob Jones", email="bob@example.com")
|
||||
|
||||
|
||||
class TestDirInit:
|
||||
def test_creates_store_dir(self, tmp_store):
|
||||
assert not tmp_store.exists()
|
||||
init_dirs()
|
||||
assert tmp_store.exists()
|
||||
|
||||
def test_store_dir_mode_700(self, tmp_store):
|
||||
init_dirs()
|
||||
mode = stat.S_IMODE(tmp_store.stat().st_mode)
|
||||
assert mode == 0o700, f"expected 0o700, got 0o{mode:03o}"
|
||||
|
||||
def test_users_dir_mode_700(self, tmp_store):
|
||||
init_dirs()
|
||||
users = tmp_store / "users"
|
||||
mode = stat.S_IMODE(users.stat().st_mode)
|
||||
assert mode == 0o700, f"expected 0o700, got 0o{mode:03o}"
|
||||
|
||||
def test_idempotent(self, tmp_store):
|
||||
init_dirs()
|
||||
init_dirs() # should not raise
|
||||
assert tmp_store.exists()
|
||||
|
||||
def test_store_exists_false_before_init(self, tmp_store):
|
||||
assert not store_exists()
|
||||
|
||||
def test_store_exists_true_after_init(self, tmp_store):
|
||||
init_dirs()
|
||||
assert store_exists()
|
||||
|
||||
|
||||
class TestWriteReadUser:
|
||||
def test_roundtrip(self, tmp_store):
|
||||
init_dirs()
|
||||
write_user(ALICE)
|
||||
loaded = read_user("alice")
|
||||
assert loaded.username == "alice"
|
||||
assert loaded.fullname == "Alice Smith"
|
||||
assert loaded.email == "alice@example.com"
|
||||
|
||||
def test_file_mode_600(self, tmp_store):
|
||||
init_dirs()
|
||||
write_user(ALICE)
|
||||
path = _users_dir() / "alice.yaml"
|
||||
mode = stat.S_IMODE(path.stat().st_mode)
|
||||
assert mode == 0o600, f"expected 0o600, got 0o{mode:03o}"
|
||||
|
||||
def test_overwrite_same_user(self, tmp_store):
|
||||
init_dirs()
|
||||
write_user(ALICE)
|
||||
updated = UserRecord(username="alice", fullname="Alice Updated", email="alice@example.com")
|
||||
write_user(updated)
|
||||
assert read_user("alice").fullname == "Alice Updated"
|
||||
|
||||
def test_read_nonexistent_raises(self, tmp_store):
|
||||
init_dirs()
|
||||
with pytest.raises(FileNotFoundError, match="nobody"):
|
||||
read_user("nobody")
|
||||
|
||||
|
||||
class TestListUsers:
|
||||
def test_empty_store(self, tmp_store):
|
||||
init_dirs()
|
||||
assert list_users() == []
|
||||
|
||||
def test_lists_all_users(self, tmp_store):
|
||||
init_dirs()
|
||||
write_user(ALICE)
|
||||
write_user(BOB)
|
||||
usernames = {u.username for u in list_users()}
|
||||
assert usernames == {"alice", "bob"}
|
||||
|
||||
def test_includes_test_users(self, tmp_store):
|
||||
init_dirs()
|
||||
write_user(ALICE)
|
||||
write_user(make_test_user(ALICE, 1))
|
||||
write_user(make_test_user(ALICE, 2))
|
||||
usernames = {u.username for u in list_users()}
|
||||
assert usernames == {"alice", "alice1", "alice2"}
|
||||
|
||||
def test_sorted_by_filename(self, tmp_store):
|
||||
init_dirs()
|
||||
write_user(BOB)
|
||||
write_user(ALICE)
|
||||
names = [u.username for u in list_users()]
|
||||
assert names == sorted(names)
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_read_config_empty(self, tmp_store):
|
||||
init_dirs()
|
||||
assert read_config() == {}
|
||||
|
||||
def test_write_read_config(self, tmp_store):
|
||||
init_dirs()
|
||||
write_config({"email": "tegwick@example.com"})
|
||||
assert read_config()["email"] == "tegwick@example.com"
|
||||
|
||||
def test_config_mode_600(self, tmp_store):
|
||||
init_dirs()
|
||||
write_config({"email": "x@y.com"})
|
||||
from local_identity.store import _config_file
|
||||
mode = stat.S_IMODE(_config_file().stat().st_mode)
|
||||
assert mode == 0o600
|
||||
|
||||
|
||||
class TestIdempotency:
|
||||
def test_reinit_produces_same_users(self, tmp_store):
|
||||
init_dirs()
|
||||
write_user(ALICE)
|
||||
write_user(make_test_user(ALICE, 1))
|
||||
write_user(make_test_user(ALICE, 2))
|
||||
|
||||
# Simulate --force re-init with same inputs
|
||||
init_dirs()
|
||||
write_user(ALICE)
|
||||
write_user(make_test_user(ALICE, 1))
|
||||
write_user(make_test_user(ALICE, 2))
|
||||
|
||||
users = {u.username: u for u in list_users()}
|
||||
assert set(users.keys()) == {"alice", "alice1", "alice2"}
|
||||
assert users["alice1"].source_user == "alice"
|
||||
assert users["alice1"].email == "alice+test1@example.com"
|
||||
91
local-identity/tests/test_user.py
Normal file
91
local-identity/tests/test_user.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Tests for UserRecord and make_test_user."""
|
||||
|
||||
import pytest
|
||||
|
||||
from local_identity.user import UserRecord, ProductionIdentity, make_test_user
|
||||
|
||||
PRIMARY = UserRecord(
|
||||
username="tegwick",
|
||||
fullname="Bernd Worsch",
|
||||
email="bernd.worsch@gmail.com",
|
||||
)
|
||||
|
||||
|
||||
class TestMakeTestUser:
|
||||
def test_username_suffix(self):
|
||||
assert make_test_user(PRIMARY, 1).username == "tegwick1"
|
||||
assert make_test_user(PRIMARY, 2).username == "tegwick2"
|
||||
|
||||
def test_fullname_suffix(self):
|
||||
assert make_test_user(PRIMARY, 1).fullname == "Bernd Worsch+test1"
|
||||
assert make_test_user(PRIMARY, 2).fullname == "Bernd Worsch+test2"
|
||||
|
||||
def test_email_plus_alias(self):
|
||||
assert make_test_user(PRIMARY, 1).email == "bernd.worsch+test1@gmail.com"
|
||||
assert make_test_user(PRIMARY, 2).email == "bernd.worsch+test2@gmail.com"
|
||||
|
||||
def test_generated_flag(self):
|
||||
assert make_test_user(PRIMARY, 1).generated is True
|
||||
|
||||
def test_source_user(self):
|
||||
assert make_test_user(PRIMARY, 1).source_user == "tegwick"
|
||||
|
||||
def test_environment_is_local(self):
|
||||
assert make_test_user(PRIMARY, 1).environment == "local"
|
||||
|
||||
def test_email_without_at(self):
|
||||
p = UserRecord(username="foo", fullname="Foo Bar", email="noemail")
|
||||
assert make_test_user(p, 1).email == "noemail+test1"
|
||||
|
||||
def test_invalid_n_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
make_test_user(PRIMARY, 0)
|
||||
|
||||
def test_deterministic(self):
|
||||
a = make_test_user(PRIMARY, 1)
|
||||
b = make_test_user(PRIMARY, 1)
|
||||
assert a.to_dict() == b.to_dict()
|
||||
|
||||
|
||||
class TestUserRecordYamlRoundtrip:
|
||||
def test_basic_roundtrip(self):
|
||||
text = PRIMARY.to_yaml()
|
||||
loaded = UserRecord.from_yaml(text)
|
||||
assert loaded.username == PRIMARY.username
|
||||
assert loaded.fullname == PRIMARY.fullname
|
||||
assert loaded.email == PRIMARY.email
|
||||
assert loaded.environment == PRIMARY.environment
|
||||
assert loaded.generated == PRIMARY.generated
|
||||
assert loaded.source_user is None
|
||||
assert loaded.production_identity is None
|
||||
|
||||
def test_roundtrip_with_production_identity(self):
|
||||
u = UserRecord(
|
||||
username="tegwick",
|
||||
fullname="Bernd Worsch",
|
||||
email="bernd.worsch@gmail.com",
|
||||
production_identity=ProductionIdentity(
|
||||
username="tegwick", realm="net-kingdom"
|
||||
),
|
||||
)
|
||||
loaded = UserRecord.from_yaml(u.to_yaml())
|
||||
assert loaded.production_identity is not None
|
||||
assert loaded.production_identity.username == "tegwick"
|
||||
assert loaded.production_identity.realm == "net-kingdom"
|
||||
|
||||
def test_roundtrip_test_user(self):
|
||||
t = make_test_user(PRIMARY, 1)
|
||||
loaded = UserRecord.from_yaml(t.to_yaml())
|
||||
assert loaded.generated is True
|
||||
assert loaded.source_user == "tegwick"
|
||||
|
||||
def test_yaml_contains_expected_fields(self):
|
||||
text = PRIMARY.to_yaml()
|
||||
assert "username: tegwick" in text
|
||||
assert "environment: local" in text
|
||||
assert "generated: false" in text
|
||||
|
||||
def test_non_ascii_roundtrip(self):
|
||||
u = UserRecord(username="amueller", fullname="Ärger Müller", email="a@b.de")
|
||||
loaded = UserRecord.from_yaml(u.to_yaml())
|
||||
assert loaded.fullname == "Ärger Müller"
|
||||
Reference in New Issue
Block a user