agilentics / boiler
"""Authentication and the Principal - the core's contract, tested without Flask.

Every test here takes a `session` and calls a core function directly. That is
possible only because nothing in app/core imports db_session or raises an HTTP
error, so these tests are also the check that the rule still holds.
"""
import pytest

from app.core import Forbidden, Invalid, NotFound, Principal, Scope
from app.core import auth as auth_core
from db.db_declarations import Organization

PASSWORD = "correcthorsebatterystaple"


@pytest.fixture
def org(session):
    o = Organization(name="Acme", slug="acme")
    session.add(o)
    session.commit()
    return o


def test_a_role_decides_the_scopes(session, org):
    user = auth_core.create_user(session, "viewer@example.com", PASSWORD)
    auth_core.add_membership(session, user, org, "viewer")

    p = auth_core.principal_for_user(session, user)
    assert p.may(Scope.READ)
    assert not p.may(Scope.WRITE)
    assert p.org_id == org.id


def test_changing_a_role_takes_effect_without_a_new_sign_in(session, org):
    """Scopes are derived on every request rather than stored in the session,
    so a demotion applies on the next click."""
    user = auth_core.create_user(session, "dev@example.com", PASSWORD)
    auth_core.add_membership(session, user, org, "member")
    assert auth_core.principal_for_user(session, user).may(Scope.WRITE)

    auth_core.add_membership(session, user, org, "viewer")
    assert not auth_core.principal_for_user(session, user).may(Scope.WRITE)


def test_an_account_in_no_organisation_cannot_resolve(session):
    user = auth_core.create_user(session, "orphan@example.com", PASSWORD)
    with pytest.raises(Forbidden):
        auth_core.principal_for_user(session, user)


def test_asking_for_an_organisation_you_are_not_in_is_not_found(session, org):
    user = auth_core.create_user(session, "dev@example.com", PASSWORD)
    auth_core.add_membership(session, user, org, "member")
    with pytest.raises(NotFound):
        auth_core.principal_for_user(session, user, org_id=org.id + 999)


def test_a_customer_principal_must_carry_an_organisation(session):
    """The guard that stops one tenant's data reaching another. It fires at
    construction, so it cannot be forgotten at a call site downstream."""
    with pytest.raises(Invalid):
        Principal(subject="x", surface="ui", org_id=None)


def test_only_a_system_caller_may_span_organisations():
    p = Principal.system()
    assert p.spans_orgs


def test_a_wrong_password_and_an_unknown_address_fail_alike(session, org):
    user = auth_core.create_user(session, "dev@example.com", PASSWORD)
    auth_core.add_membership(session, user, org, "member")

    with pytest.raises(Forbidden) as wrong:
        auth_core.authenticate_password(session, "dev@example.com", "not-it")
    with pytest.raises(Forbidden) as unknown:
        auth_core.authenticate_password(session, "nobody@example.com", "not-it")

    # One message for both, or the login form is an account enumerator.
    assert str(wrong.value) == str(unknown.value)


def test_repeated_failures_lock_the_account(session, org):
    user = auth_core.create_user(session, "dev@example.com", PASSWORD)
    auth_core.add_membership(session, user, org, "member")

    for _ in range(auth_core.MAX_FAILED_LOGINS):
        with pytest.raises(Forbidden):
            auth_core.authenticate_password(session, "dev@example.com", "not-it")

    assert user.locked_until is not None
    # The correct password is refused too, for as long as the lockout lasts.
    with pytest.raises(Forbidden):
        auth_core.authenticate_password(session, "dev@example.com", PASSWORD)


def test_a_short_password_is_refused_before_anything_is_written(session):
    with pytest.raises(Invalid):
        auth_core.create_user(session, "dev@example.com", "short")
    from db.db_declarations import User
    assert session.query(User).count() == 0


def test_an_identity_provider_account_has_no_hash(session, org):
    """password_hash is nullable so "signs in elsewhere" has an honest
    representation - and so a password sign-in cannot succeed against it."""
    user = auth_core.create_user(session, "sso@example.com", None)
    auth_core.add_membership(session, user, org, "member")
    assert user.password_hash is None
    with pytest.raises(Forbidden):
        auth_core.authenticate_password(session, "sso@example.com", "")