agilentics / boiler
"""Google sign-in, without talking to Google.

The identity resolution (app/core/oauth) is tested directly; the endpoint is
tested up to the point where it would make a network call. What is deliberately
covered is the security-relevant half - unset means off, state must match, an
unverified email is refused - because that is what a later edit is most likely to
break silently.
"""
import pytest

import settings
from app.core import auth as auth_core
from app.core import oauth as oauth_core
from db.db_declarations import Organization, User

PASSWORD = "correcthorsebatterystaple"


@pytest.fixture
def google_configured(monkeypatch):
    monkeypatch.setattr(settings.Config, "GOOGLE_CLIENT_ID", "client-id.apps.googleusercontent.com")
    monkeypatch.setattr(settings.Config, "GOOGLE_CLIENT_SECRET", "client-secret")


# --- configuration gate ------------------------------------------------------


def test_unset_config_means_the_feature_is_off(app_client):
    """The stance the whole app takes: no credential registered anywhere, and
    everything still runs. Both routes bounce rather than erroring."""
    from app.endpoints.oauth import is_enabled

    assert not is_enabled()
    for path in ("/auth/google/start", "/auth/google/callback"):
        r = app_client.get(path)
        assert r.status_code == 302
        assert r.headers["Location"].endswith("/login")


def test_the_button_is_hidden_when_unconfigured(app_client):
    html = app_client.get("/login").get_data(as_text=True)
    assert "/auth/google/start" not in html


def test_the_button_appears_when_configured(app_client, google_configured):
    html = app_client.get("/login").get_data(as_text=True)
    assert "/auth/google/start" in html


def test_the_oauth_paths_are_public(app_client):
    from app.endpoints.crud import is_public

    assert is_public("/auth/google/start")
    assert is_public("/auth/google/callback")


# --- the redirect dance ------------------------------------------------------


def test_start_redirects_to_google_and_stashes_state(app_client, google_configured):
    from app.endpoints.oauth import NONCE_KEY, STATE_KEY

    r = app_client.get("/auth/google/start")
    assert r.status_code == 302

    location = r.headers["Location"]
    assert location.startswith("https://accounts.google.com/o/oauth2/v2/auth")
    assert "response_type=code" in location
    assert "prompt=select_account" in location
    # Built from the request only because the test client's host is loopback;
    # any other host without PUBLIC_BASE_URL fails closed. See crud.public_base_url.
    assert "redirect_uri=http%3A%2F%2Flocalhost%2Fauth%2Fgoogle%2Fcallback" in location

    with app_client.session_transaction() as sess:
        assert sess[STATE_KEY] and sess[NONCE_KEY]
        assert sess[STATE_KEY] in location


def test_a_callback_with_no_state_is_refused(app_client, google_configured):
    """A callback nobody started - the forged-request case `state` exists for."""
    r = app_client.get("/auth/google/callback?code=abc&state=made-up")
    assert r.status_code == 302
    assert r.headers["Location"].endswith("/login?error=google")


def test_a_callback_whose_state_does_not_match_is_refused(app_client, google_configured):
    from app.endpoints.oauth import STATE_KEY

    with app_client.session_transaction() as sess:
        sess[STATE_KEY] = "the-real-state"

    r = app_client.get("/auth/google/callback?code=abc&state=not-the-real-state")
    assert r.headers["Location"].endswith("/login?error=google")


def test_state_is_single_use(app_client, google_configured):
    """Popped rather than read, so a replayed callback finds nothing to match."""
    from app.endpoints.oauth import STATE_KEY

    with app_client.session_transaction() as sess:
        sess[STATE_KEY] = "state-x"

    # No code, so it fails after the state check - which is what consumes it.
    app_client.get("/auth/google/callback?state=state-x")

    with app_client.session_transaction() as sess:
        assert STATE_KEY not in sess


def test_consent_denied_returns_to_login(app_client, google_configured):
    r = app_client.get("/auth/google/callback?error=access_denied")
    assert r.headers["Location"].endswith("/login?error=google")


# --- identity resolution -----------------------------------------------------


def test_a_new_google_user_gets_an_account_and_a_tenant(session):
    user = oauth_core.find_or_create_google_user(
        session, google_sub="sub-1", email="Ada@Example.com", name="Ada Lovelace")

    assert user.email == "ada@example.com"          # normalised
    assert user.google_sub == "sub-1"
    assert user.password_hash is None               # provider-only account

    p = auth_core.principal_for_user(session, user)
    org = session.get(Organization, p.org_id)
    assert org.slug == "ada-lovelace"
    assert session.query(Organization).count() == 1


def test_signing_in_again_returns_the_same_account(session):
    first = oauth_core.find_or_create_google_user(
        session, google_sub="sub-1", email="ada@example.com", name="Ada")
    again = oauth_core.find_or_create_google_user(
        session, google_sub="sub-1", email="ada@example.com", name="Ada")

    assert first.id == again.id
    assert session.query(User).count() == 1
    assert session.query(Organization).count() == 1


def test_google_links_to_an_existing_password_account(session):
    """Safe only because the endpoint refuses a claim without email_verified -
    otherwise this is a way to seize somebody's account."""
    org = Organization(name="Acme", slug="acme")
    session.add(org)
    session.commit()
    existing = auth_core.create_user(session, "dev@example.com", PASSWORD)
    auth_core.add_membership(session, existing, org, "member")

    linked = oauth_core.find_or_create_google_user(
        session, google_sub="sub-9", email="dev@example.com")

    assert linked.id == existing.id
    assert linked.google_sub == "sub-9"
    # The password still works: linking adds a way in, it does not replace one.
    assert auth_core.authenticate_password(session, "dev@example.com", PASSWORD)
    # And no second organisation was provisioned for an account that had one.
    assert session.query(Organization).count() == 1


def test_a_second_person_of_the_same_name_gets_a_free_slug(session):
    oauth_core.find_or_create_google_user(
        session, google_sub="sub-1", email="ada@one.com", name="Ada")
    oauth_core.find_or_create_google_user(
        session, google_sub="sub-2", email="ada@two.com", name="Ada")

    slugs = {o.slug for o in session.query(Organization).all()}
    assert slugs == {"ada", "ada-2"}


def test_a_name_that_does_not_slugify_still_provisions(session):
    """A display name of punctuation, or none at all, must not produce a slug the
    validator rejects - this path never asked anyone for one."""
    user = oauth_core.find_or_create_google_user(
        session, google_sub="sub-1", email="someone@example.com", name="!!!")
    p = auth_core.principal_for_user(session, user)
    assert session.get(Organization, p.org_id).slug == "someone"


def test_a_malformed_claim_pair_is_a_programming_error(session):
    with pytest.raises(ValueError):
        oauth_core.find_or_create_google_user(session, google_sub="", email="a@b.com")
    with pytest.raises(ValueError):
        oauth_core.find_or_create_google_user(session, google_sub="s", email="not-an-email")