The password change must stay below the domain's lockout threshold. The policy now carries lockout_threshold and lockout_window, from the domain or from the fine-grained policy that applies. AD-2's read allow-list names the new attributes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
112 lines
3.8 KiB
Python
112 lines
3.8 KiB
Python
"""Contract tests for the in-memory directory that the other tickets build against."""
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
|
|
from portal.adapters.directory import (
|
|
WRITABLE_ATTRIBUTES,
|
|
DirectoryUnavailable,
|
|
FakeDirectory,
|
|
Forbidden,
|
|
PasswordRejected,
|
|
ValidationFailed,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def directory():
|
|
d = FakeDirectory()
|
|
d.add_user("Alice", given_name="Alice", mail="alice@example.test", is_publisher=True)
|
|
d.add_user("gone", enabled=False)
|
|
d.add_user("locked", locked=True)
|
|
d.add_user("expired", password_last_set=None)
|
|
return d
|
|
|
|
|
|
def test_find_user_is_case_insensitive_and_keeps_the_exact_name(directory):
|
|
assert directory.find_user("alice").sam == "Alice"
|
|
assert directory.find_user("nobody") is None
|
|
assert directory.find_user("") is None
|
|
|
|
|
|
def test_account_state_is_reported(directory):
|
|
assert directory.find_user("gone").enabled is False
|
|
assert directory.find_user("locked").locked is True
|
|
assert directory.find_user("expired").must_change_password is True
|
|
assert directory.find_user("Alice").is_publisher is True
|
|
assert directory.find_user("Alice").is_admin is False
|
|
|
|
|
|
def test_verify_password(directory):
|
|
assert directory.verify_password("alice", "Correct-Horse-9") is True
|
|
assert directory.verify_password("alice", "wrong") is False
|
|
assert directory.verify_password("nobody", "Correct-Horse-9") is False
|
|
|
|
|
|
def test_an_empty_password_is_never_valid(directory):
|
|
directory.add_user("blank", password="")
|
|
assert directory.verify_password("blank", "") is False
|
|
|
|
|
|
def test_allowed_attributes_can_be_written_and_cleared(directory):
|
|
directory.update_attributes("alice", {"mobile": "+49 1", "givenName": "Al"})
|
|
user = directory.find_user("alice")
|
|
assert (user.mobile, user.given_name) == ("+49 1", "Al")
|
|
directory.update_attributes("alice", {"mobile": None})
|
|
assert directory.find_user("alice").mobile == ""
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"name", ["description", "memberOf", "unicodePwd", "sAMAccountName", "userAccountControl"]
|
|
)
|
|
def test_other_attributes_are_refused_before_anything_is_written(directory, name):
|
|
with pytest.raises(Forbidden):
|
|
directory.update_attributes("alice", {"givenName": "Changed", name: "x"})
|
|
assert directory.find_user("alice").given_name == "Alice"
|
|
|
|
|
|
def test_writing_an_unknown_user_fails(directory):
|
|
with pytest.raises(ValidationFailed):
|
|
directory.update_attributes("nobody", {"mobile": "1"})
|
|
|
|
|
|
def test_writable_list_is_exactly_the_agreed_six():
|
|
assert set(WRITABLE_ATTRIBUTES) == {
|
|
"givenName",
|
|
"sn",
|
|
"displayName",
|
|
"mail",
|
|
"telephoneNumber",
|
|
"mobile",
|
|
}
|
|
|
|
|
|
def test_password_policy_is_returned(directory):
|
|
policy = directory.password_policy("alice")
|
|
assert policy.min_length == 8
|
|
assert policy.min_age == timedelta(0)
|
|
assert policy.lockout_threshold == 5
|
|
|
|
|
|
def test_password_change_needs_the_current_password(directory):
|
|
with pytest.raises(PasswordRejected) as exc:
|
|
directory.change_password("alice", "wrong", "Another-Pass-7!")
|
|
assert exc.value.reason == "wrong_current"
|
|
directory.change_password("alice", "Correct-Horse-9", "Another-Pass-7!")
|
|
assert directory.verify_password("alice", "Another-Pass-7!") is True
|
|
assert directory.find_user("alice").password_last_set > datetime.now(UTC) - timedelta(minutes=1)
|
|
|
|
|
|
@pytest.mark.parametrize("new", ["short1A", "alllowercase-long", "Correct-Horse-9"])
|
|
def test_policy_violations_are_reported_as_policy(directory, new):
|
|
with pytest.raises(PasswordRejected) as exc:
|
|
directory.change_password("alice", "Correct-Horse-9", new)
|
|
assert exc.value.reason == "policy"
|
|
|
|
|
|
def test_an_outage_is_a_typed_error(directory):
|
|
directory.unavailable = True
|
|
with pytest.raises(DirectoryUnavailable):
|
|
directory.find_user("alice")
|