1630821 ee8fef9 608d8b2 85b8192 7f2db6c 608d8b2 4a34d33 df91ffa 85b8192 df91ffa 85b8192 df91ffa 85b8192 df91ffa 7f2db6c 85b8192 71df48e 7f2db6c 1630821 73c9c30 1630821 ee8fef9 e6c6972 71df48e 85b8192 ee8fef9 e6c6972 4a34d33 85b8192 df91ffa 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 608d8b2 73c9c30 608d8b2 85b8192 df91ffa 73c9c30 df91ffa 73c9c30 df91ffa 85b8192 73c9c30 85b8192 608d8b2 73c9c30 608d8b2 85b8192 73c9c30 85b8192 608d8b2 73c9c30 85b8192 608d8b2 73c9c30 85b8192 608d8b2 73c9c30 85b8192 36c2fbe 73c9c30 36c2fbe 608d8b2 73c9c30 60679ee 608d8b2 73c9c30 8c60507 608d8b2 73c9c30 8c60507 60679ee 73c9c30 60679ee 608d8b2 73c9c30 608d8b2 73c9c30 60679ee 608d8b2 73c9c30 608d8b2 73c9c30 608d8b2 73c9c30 608d8b2 73c9c30 608d8b2 73c9c30 ee8fef9 73c9c30 ee8fef9 73c9c30 608d8b2 7f2db6c 73c9c30 7f2db6c 73c9c30 7f2db6c 73c9c30 7f2db6c 73c9c30 7f2db6c 73c9c30 7f2db6c 608d8b2 73c9c30 608d8b2 73c9c30 4a34d33 73c9c30 4a34d33 578d026 73c9c30 578d026 73c9c30 578d026 b96c6f3 73c9c30 b96c6f3 4a34d33 73c9c30 4a34d33 73c9c30 4a34d33 73c9c30 4a34d33 73c9c30 4a34d33 df91ffa 73c9c30 df91ffa 71df48e 73c9c30 71df48e 73c9c30 71df48e 4a34d33 73c9c30 4a34d33 73c9c30 ee8fef9 73c9c30 ee8fef9 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30 1630821 73c9c30
import asyncio
import json
import os
import sys
from collections.abc import Generator
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import AsyncMock, patch
from urllib.parse import parse_qs, urlparse

import httpx
import pytest
from fastapi import FastAPI, HTTPException, Request
from fastapi.testclient import TestClient
from httpx import ASGITransport
from pydantic import ValidationError

from pygitweb.auth import ACCESS_TOKEN_COOKIE_NAME
from pygitweb.auth_config import AuthConfig, HttpKey, LocalUser, init_auth_config, write_auth_config
from pygitweb.auth_oauth import (
    OAUTH_STATE_COOKIE_NAME,
    _exchange_code,
    _fetch_github_profile,
    _fetch_google_profile,
    _fetch_profile,
    _seal_oauth_state,
    is_oauth_email_allowed,
)
from pygitweb.config import Settings, settings
from pygitweb.conftest import clear_client_cookies
from pygitweb.gravatar import gravatar_url
from pygitweb.main import app
from pygitweb.password_hash import hash_password, verify_password
from pygitweb.permissions import PERMISSION_ADD_PROJECTS, PermissionsMap
from pygitweb.sessions import get_session

_REAL_ASYNC_CLIENT = httpx.AsyncClient


class _DummyResponse:
    def __init__(self, payload: object, is_success: bool = True) -> None:
        self._payload = payload
        self.is_success = is_success

    def raise_for_status(self) -> None:
        return None

    def json(self) -> object:
        return self._payload


_LOCAL_ADMIN = AuthConfig(
    auth_mode="local",
    local_users=[LocalUser(user="admin", password="secret")],
)


def _build_test_idp_app(
    email: str = "user@example.com",
    preferred_username: str = "oauthuser",
) -> FastAPI:
    idp = FastAPI()

    @idp.post("/token")
    async def google_token(request: Request) -> dict[str, str]:
        form = await request.form()
        grant_type = str(form.get("grant_type") or "")
        code = str(form.get("code") or "")
        if grant_type != "authorization_code" or code != "auth-code":
            return {"error": "invalid_grant"}
        return {"access_token": "idp-access-token"}

    @idp.post("/login/oauth/access_token")
    async def github_token(request: Request) -> dict[str, str]:
        form = await request.form()
        code = str(form.get("code") or "")
        if code != "auth-code":
            return {"error": "bad_code"}
        return {"access_token": "idp-access-token"}

    @idp.get("/v1/userinfo")
    async def google_userinfo() -> dict[str, str]:
        return {"sub": "oauth-sub-1", "email": email, "preferred_username": preferred_username}

    @idp.get("/user")
    async def github_user() -> dict[str, object]:
        return {"id": "123", "login": preferred_username, "email": None}

    @idp.get("/user/emails")
    async def github_emails() -> list[dict[str, object]]:
        return [
            {"email": email, "primary": True, "verified": True},
            {"email": "other@example.com", "primary": False, "verified": True},
        ]

    return idp


@pytest.fixture
def client() -> Generator[TestClient, None, None]:
    with TestClient(app) as c:
        yield c


@pytest.fixture
def local_auth_config() -> Generator[AuthConfig, None, None]:
    with (
        patch("pygitweb.auth_config.auth_config", _LOCAL_ADMIN),
        patch("pygitweb.auth.auth_config", _LOCAL_ADMIN),
        patch("pygitweb.main.auth_config", _LOCAL_ADMIN),
    ):
        yield _LOCAL_ADMIN


def test_token_disabled(client: TestClient) -> None:
    with patch.object(settings, "AUTH", False):
        r = client.post("/token", data={"username": "a", "password": "b"})
        assert r.status_code == 400
        assert r.json()["detail"] == "Authentication is disabled"


def test_token_success(client: TestClient, local_auth_config: AuthConfig) -> None:
    with patch.object(settings, "AUTH", True):
        r = client.post("/token", data={"username": "admin", "password": "secret"})
    assert r.status_code == 200
    data = r.json()
    assert data["token_type"] == "bearer"
    assert isinstance(data["access_token"], str)
    assert len(data["access_token"]) >= 32
    assert data["access_token"] != "admin"


def test_token_wrong_password(client: TestClient, local_auth_config: AuthConfig) -> None:
    with patch.object(settings, "AUTH", True):
        r = client.post("/token", data={"username": "admin", "password": "wrong"})
    assert r.status_code == 400


def test_users_me_with_bearer(client: TestClient, local_auth_config: AuthConfig) -> None:
    with patch.object(settings, "AUTH", True):
        tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
        r = client.get("/users/me", headers={"Authorization": f"Bearer {tok}"})
        assert r.status_code == 200
        assert r.json()["username"] == "admin"


def test_login_page_is_a_centered_card(client: TestClient, local_auth_config: AuthConfig) -> None:
    with patch.object(settings, "AUTH", True):
        page = client.get("/login")
        failed = client.post(
            "/login",
            data={"username": "admin", "password": "wrong", "next": "/"},
        )
    assert page.status_code == 200
    assert 'class="page page-center"' in page.text
    assert 'class="theme-dark-auto"' in page.text
    assert 'height="120"' in page.text
    assert "Login to your account" in page.text
    assert 'class="btn btn-primary w-100"' in page.text
    assert "navbar-vertical" not in page.text
    assert "I forgot password" not in page.text
    assert "Remember me" not in page.text
    assert failed.status_code == 200
    assert "Incorrect username or password." in failed.text
    assert 'class="page page-center"' in failed.text


def test_login_form_sets_cookie_and_users_me(client: TestClient, local_auth_config: AuthConfig) -> None:
    with patch.object(settings, "AUTH", True):
        r = client.post(
            "/login",
            data={"username": "admin", "password": "secret", "next": "/"},
            follow_redirects=False,
        )
        assert r.status_code == 303
        r2 = client.get("/users/me")
        assert r2.status_code == 200
        assert r2.json()["username"] == "admin"


def test_logout_revokes_cookie_session(client: TestClient, local_auth_config: AuthConfig) -> None:
    with patch.object(settings, "AUTH", True):
        client.post(
            "/login",
            data={"username": "admin", "password": "secret", "next": "/"},
            follow_redirects=False,
        )
        assert client.get("/users/me").status_code == 200
        r_out = client.get("/logout", params={"next": "/"}, follow_redirects=False)
        assert r_out.status_code == 303
        assert client.get("/users/me").status_code == 401


def test_logout_with_bearer_revokes_token(client: TestClient, local_auth_config: AuthConfig) -> None:
    with patch.object(settings, "AUTH", True):
        tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
        headers = {"Authorization": f"Bearer {tok}"}
        assert client.get("/users/me", headers=headers).status_code == 200
        r_out = client.get("/logout", params={"next": "/"}, headers=headers, follow_redirects=False)
        assert r_out.status_code == 303
        assert client.get("/users/me", headers=headers).status_code == 401


def test_projectnamevalid_no_auth_required_when_auth_disabled(client: TestClient) -> None:
    with patch.object(settings, "AUTH", False), patch("pygitweb.main.project_visible_in_list", return_value=False):
        r = client.get("/projectnamevalid", params={"name": "newproj"})
    assert r.status_code == 200


def test_projectnamevalid_401_without_credentials_when_auth_enabled(
    client: TestClient, local_auth_config: AuthConfig
) -> None:
    with patch.object(settings, "AUTH", True):
        r = client.get("/projectnamevalid", params={"name": "newproj"})
    assert r.status_code == 401


def test_projectnamevalid_ok_with_bearer_when_auth_enabled(client: TestClient, local_auth_config: AuthConfig) -> None:
    cfg = local_auth_config.model_copy(
        update={
            "oauth_permissions": PermissionsMap.model_validate({
                "*": [],
                PERMISSION_ADD_PROJECTS: ["admin"],
            }),
        },
    )
    with (
        patch.object(settings, "AUTH", True),
        patch("pygitweb.main.project_visible_in_list", return_value=False),
        patch("pygitweb.auth_config.auth_config", cfg),
        patch("pygitweb.auth.auth_config", cfg),
        patch("pygitweb.main.auth_config", cfg),
    ):
        tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
        r = client.get(
            "/projectnamevalid",
            params={"name": "newproj"},
            headers={"Authorization": f"Bearer {tok}"},
        )
    assert r.status_code == 200


def test_auth_config_init_creates_file_only_when_auth_enabled(tmp_path: Path) -> None:
    cfg_path = tmp_path / "auth.json"
    s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
    assert not cfg_path.is_file()
    cfg = init_auth_config(s)
    assert cfg_path.is_file()
    assert cfg.local_users[0].user == "admin"
    assert len(cfg.salt) == 16
    assert cfg.local_users[0].pass_hash.startswith("scrypt$")
    assert cfg.local_users[0].password is None
    on_disk = json.loads(cfg_path.read_text(encoding="utf-8"))
    assert len(on_disk["salt"]) == 16
    user_entry = on_disk["local_users"][0]
    assert "pass" not in user_entry
    assert "pass_hash" in user_entry
    if sys.platform != "win32":
        assert oct(os.stat(cfg_path).st_mode & 0o777) == "0o600"


def test_auth_config_no_file_when_auth_disabled(tmp_path: Path) -> None:
    from pygitweb.auth_config import init_auth_config
    from pygitweb.config import Settings

    cfg_path = tmp_path / "auth.json"
    s = Settings(AUTH=False, AUTH_CONFIG=str(cfg_path))
    init_auth_config(s)
    assert not cfg_path.is_file()


def test_auth_config_does_not_regenerate_missing_password(tmp_path: Path) -> None:
    cfg_path = tmp_path / "auth.json"
    write_auth_config(
        cfg_path,
        AuthConfig(auth_mode="local", local_users=[LocalUser(user="admin", password="")]),
    )
    s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
    cfg = init_auth_config(s)
    assert cfg.local_users[0].password == ""
    assert cfg.local_users[0].pass_hash == ""


def test_auth_config_adds_salt_to_existing_file(tmp_path: Path) -> None:
    cfg_path = tmp_path / "auth.json"
    write_auth_config(
        cfg_path,
        AuthConfig(
            auth_mode="local",
            local_users=[LocalUser(user="admin", pass_hash=hash_password("secret"))],
        ),
    )
    s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
    cfg = init_auth_config(s)
    assert len(cfg.salt) == 16
    assert verify_password("secret", cfg.local_users[0].pass_hash)


def test_auth_config_migrates_plaintext_pass_to_pass_hash(tmp_path: Path) -> None:
    cfg_path = tmp_path / "auth.json"
    cfg_path.write_text(
        '{"auth_mode": "local", "local_users": [{"user": "admin", "pass": "secret1234"}]}\n',
        encoding="utf-8",
    )
    if sys.platform != "win32":
        os.chmod(cfg_path, 0o600)
    s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
    cfg = init_auth_config(s)
    assert cfg.local_users[0].password is None
    assert verify_password("secret1234", cfg.local_users[0].pass_hash, config_salt=cfg.salt)
    on_disk = cfg_path.read_text(encoding="utf-8")
    root = json.loads(on_disk)
    assert len(root["salt"]) == 16
    user_entry = root["local_users"][0]
    assert "pass" not in user_entry
    assert "pass_hash" in user_entry


def test_auth_config_keeps_hashed_http_key(tmp_path: Path) -> None:
    cfg_path = tmp_path / "auth.json"
    key_hash = hash_password("plain-key-value")
    cfg_path.write_text(
        json.dumps({
            "auth_mode": "local",
            "salt": "0123456789abcdef",
            "local_users": [
                {
                    "user": "admin",
                    "pass": "secret1234",
                    "email": "admin@example.com",
                    "assume_anonymous": True,
                    "keys": [
                        {
                            "id": "laptop",
                            "secret": key_hash,
                            "readonly": True,
                            "time_issued": "2026-10-08T12:00:00Z",
                            "time_expiry": "2027-10-08T12:00:00Z",
                        }
                    ],
                }
            ],
        }),
        encoding="utf-8",
    )
    if sys.platform != "win32":
        os.chmod(cfg_path, 0o600)
    s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
    cfg = init_auth_config(s)
    user = cfg.local_users[0]
    assert user.password is None
    assert user.assume_anonymous is True
    assert user.email == "admin@example.com"
    assert verify_password("secret1234", user.pass_hash, config_salt=cfg.salt)
    assert len(user.keys) == 1
    key = user.keys[0]
    assert key.id == "laptop"
    assert key.readonly is True
    assert key.time_issued == datetime(2026, 10, 8, 12, 0, tzinfo=UTC)
    assert key.time_expiry == datetime(2027, 10, 8, 12, 0, tzinfo=UTC)
    assert key.secret == key_hash
    assert verify_password("plain-key-value", key.secret)
    on_disk = json.loads(cfg_path.read_text(encoding="utf-8"))
    stored = on_disk["local_users"][0]
    assert "pass" not in stored
    assert stored["keys"][0]["secret"] == key_hash
    assert stored["keys"][0]["readonly"] is True
    assert stored["keys"][0]["time_expiry"] == "2027-10-08T12:00:00Z"


def test_auth_config_rejects_plaintext_http_key(tmp_path: Path) -> None:
    cfg_path = tmp_path / "auth.json"
    original = json.dumps({
        "auth_mode": "local",
        "local_users": [
            {
                "user": "admin",
                "pass": "secret1234",
                "keys": [
                    {
                        "id": "laptop",
                        "secret": "plain-key-value",
                        "time_issued": "2026-10-08T12:00:00Z",
                    }
                ],
            }
        ],
    })
    cfg_path.write_text(original, encoding="utf-8")
    if sys.platform != "win32":
        os.chmod(cfg_path, 0o600)
    s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
    with pytest.raises(ValidationError, match="scrypt password hash"):
        init_auth_config(s)
    assert cfg_path.read_text(encoding="utf-8") == original


def test_http_key_rejects_duplicate_ids() -> None:
    issued = datetime(2026, 10, 8, tzinfo=UTC)
    with pytest.raises(ValidationError, match="duplicate http key id"):
        LocalUser(
            user="admin",
            password="secret",
            keys=[
                HttpKey(id="laptop", secret=hash_password("one"), readonly=False, time_issued=issued),
                HttpKey(id="laptop", secret=hash_password("two"), readonly=True, time_issued=issued),
            ],
        )


def test_http_key_strips_id_and_secret_and_rejects_blanks() -> None:
    issued = datetime(2026, 10, 8, tzinfo=UTC)
    key_hash = hash_password("secret")
    key = HttpKey(id=" laptop ", secret=f"  {key_hash}  ", time_issued=issued)
    assert key.id == "laptop"
    assert key.secret == key_hash
    assert key.time_expiry is None
    with pytest.raises(ValidationError, match="non-empty"):
        HttpKey(id="   ", secret=key_hash, time_issued=issued)
    with pytest.raises(ValidationError, match="non-empty"):
        HttpKey(id="laptop", secret="  ", time_issued=issued)
    with pytest.raises(ValidationError, match="scrypt password hash"):
        HttpKey(id="laptop", secret="plaintext", time_issued=issued)
    with pytest.raises(ValidationError):
        HttpKey.model_validate({"id": 1, "secret": key_hash, "time_issued": issued.isoformat()})


def test_http_key_rejects_expiry_before_issue() -> None:
    issued = datetime(2026, 10, 8, tzinfo=UTC)
    key_hash = hash_password("secret")
    with pytest.raises(ValidationError, match="time_expiry is before time_issued"):
        HttpKey(
            id="laptop",
            secret=key_hash,
            time_issued=issued,
            time_expiry=datetime(2026, 10, 7, tzinfo=UTC),
        )
    naive_expiry = datetime(2026, 10, 7, 0, 0)
    with pytest.raises(ValidationError, match="time_expiry is before time_issued"):
        HttpKey(id="laptop", secret=key_hash, time_issued=issued, time_expiry=naive_expiry)
    active = HttpKey(
        id="laptop",
        secret=key_hash,
        time_issued=issued,
        time_expiry=datetime(2026, 10, 9),
    )
    assert active.is_expired(datetime(2026, 10, 8, 12, tzinfo=UTC)) is False
    assert active.is_expired(datetime(2026, 10, 9)) is True


@pytest.mark.skipif(sys.platform == "win32", reason="Unix file modes")
def test_auth_config_rejects_insecure_permissions(tmp_path: Path) -> None:
    from pygitweb.auth_config import AuthConfigPermissionError, init_auth_config, write_auth_config
    from pygitweb.config import Settings

    cfg_path = tmp_path / "auth.json"
    write_auth_config(
        cfg_path,
        AuthConfig(auth_mode="local", local_users=[LocalUser(user="admin", password="secret")]),
    )
    os.chmod(cfg_path, 0o644)
    s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
    with pytest.raises(AuthConfigPermissionError, match="0600"):
        init_auth_config(s)


def test_local_login_stores_session_audit_fields(client: TestClient, local_auth_config: AuthConfig) -> None:
    with patch.object(settings, "AUTH", True):
        client.post(
            "/login",
            data={"username": "admin", "password": "secret", "next": "/"},
            follow_redirects=False,
        )
        tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
    rec = get_session(tok)
    assert rec is not None
    assert rec.username == "admin"
    assert rec.auth_method == "local"
    assert rec.subject is None
    assert rec.email is None


def test_oauth_allowed_emails_glob() -> None:
    cfg = AuthConfig(auth_mode="oauth", oauth_allowed_emails=["*@example.com", "guest@corp.org"])
    assert is_oauth_email_allowed(cfg, "alice@example.com")
    assert is_oauth_email_allowed(cfg, "Alice@Example.COM")
    assert is_oauth_email_allowed(cfg, "guest@corp.org")
    assert not is_oauth_email_allowed(cfg, "alice@other.org")
    assert not is_oauth_email_allowed(cfg, None)


def test_oauth_allowed_emails_exact_match() -> None:
    cfg = AuthConfig(auth_mode="oauth", oauth_allowed_emails=["only@example.com"])
    assert is_oauth_email_allowed(cfg, "only@example.com")
    assert not is_oauth_email_allowed(cfg, "other@example.com")


def test_anonymous_user_assumes_local_public_role_for_read_permissions() -> None:
    from pygitweb.auth import can_read_project

    cfg = AuthConfig(
        auth_mode="local",
        local_users=[
            LocalUser(
                user="public",
                password="secret",
                email="public@example.com",
                assume_anonymous=True,
            ),
        ],
        oauth_permissions=PermissionsMap.model_validate({
            "*": [],
            "pgw.read.secret": ["public@example.com"],
        }),
    )
    with (
        patch.object(settings, "AUTH", True),
        patch("pygitweb.auth.auth_config", cfg),
    ):
        assert can_read_project("secret", None)
        assert not can_read_project("other", None)


_OAUTH_CFG = AuthConfig(
    auth_mode="oauth",
    oauth_provider="google",
    oauth_client_id="test-client-id",
    oauth_client_secret="test-client-secret",
    oauth_redirect_uri="http://testserver/auth/oauth/callback",
    oauth_allowed_emails=[],
    local_users=[],
)


@pytest.fixture
def oauth_auth_config() -> Generator[AuthConfig, None, None]:
    with (
        patch("pygitweb.auth_config.auth_config", _OAUTH_CFG),
        patch("pygitweb.auth.auth_config", _OAUTH_CFG),
        patch("pygitweb.auth_oauth.auth_config", _OAUTH_CFG),
        patch("pygitweb.main.auth_config", _OAUTH_CFG),
    ):
        yield _OAUTH_CFG


def test_oauth_start_redirects_to_provider(client: TestClient, oauth_auth_config: AuthConfig) -> None:
    with patch.object(settings, "AUTH", True):
        r = client.get("/auth/oauth/start", params={"next": "/boards"}, follow_redirects=False)
    assert r.status_code == 303
    assert "accounts.google.com" in r.headers["location"]
    assert OAUTH_STATE_COOKIE_NAME in r.cookies


def test_oauth_callback_creates_session(client: TestClient, oauth_auth_config: AuthConfig) -> None:
    sealed = _seal_oauth_state(
        {"state": "idp-state", "next": "/", "code_verifier": "verifier"},
        oauth_auth_config.oauth_client_secret,
    )
    token_payload = {"access_token": "provider-token"}
    profile = {"sub": "oauth-sub-1", "email": "user@example.com", "preferred_username": "oauthuser"}
    with (
        patch.object(settings, "AUTH", True),
        patch("pygitweb.auth_oauth._exchange_code", new_callable=AsyncMock, return_value=token_payload),
        patch("pygitweb.auth_oauth._fetch_profile", new_callable=AsyncMock, return_value=profile),
    ):
        client.cookies.set(OAUTH_STATE_COOKIE_NAME, sealed)
        r = client.get(
            "/auth/oauth/callback",
            params={"code": "auth-code", "state": "idp-state"},
            follow_redirects=False,
        )
        assert r.status_code == 303
        rec = get_session(r.cookies["pygitweb_access_token"])
        assert rec is not None
        assert rec.username == "oauthuser"
        assert rec.subject == "oauthuser"
        assert rec.email == "user@example.com"
        assert rec.auth_method == "oauth"
        assert client.get("/users/me").json()["username"] == "oauthuser"
        status = client.get("/auth/status").json()
        assert status["username"] == "oauthuser"
        assert status["gravatar_url"] == gravatar_url("user@example.com")
        clear_client_cookies(client)


def test_oauth_flow_with_asgi_idp(client: TestClient, oauth_auth_config: AuthConfig) -> None:
    idp_app = _build_test_idp_app()

    async def runner() -> None:
        transport = ASGITransport(app=idp_app)

        def _idp_client_factory(*_: object, **__: object) -> httpx.AsyncClient:
            return _REAL_ASYNC_CLIENT(transport=transport, base_url="https://idp.test")

        with (
            patch.object(settings, "AUTH", True),
            patch("pygitweb.auth_oauth.httpx.AsyncClient", side_effect=_idp_client_factory),
        ):
            start = client.get("/auth/oauth/start", params={"next": "/boards"}, follow_redirects=False)
            assert start.status_code == 303
            location = start.headers["location"]
            state_values = parse_qs(urlparse(location).query).get("state", [])
            assert state_values
            state = state_values[0]
            callback = client.get(
                "/auth/oauth/callback",
                params={"code": "auth-code", "state": state},
                follow_redirects=False,
            )
            assert callback.status_code == 303
            rec = get_session(callback.cookies[ACCESS_TOKEN_COOKIE_NAME])
            assert rec is not None
            assert rec.username == "oauthuser"
            assert rec.subject == "oauthuser"
            assert rec.email == "user@example.com"
            assert rec.auth_method == "oauth"

    asyncio.run(runner())


def test_auth_status_no_gravatar_for_local_login(client: TestClient, local_auth_config: AuthConfig) -> None:
    with patch.object(settings, "AUTH", True):
        client.post(
            "/login",
            data={"username": "admin", "password": "secret", "next": "/"},
            follow_redirects=False,
        )
        status = client.get("/auth/status").json()
    assert status["username"] == "admin"
    assert status["gravatar_url"] is None


def test_local_user_email_gravatar(client: TestClient) -> None:
    cfg = AuthConfig(
        auth_mode="local",
        local_users=[LocalUser(user="admin", password="secret", email="  admin@example.com  ")],
    )
    with (
        patch.object(settings, "AUTH", True),
        patch("pygitweb.auth_config.auth_config", cfg),
        patch("pygitweb.auth.auth_config", cfg),
        patch("pygitweb.main.auth_config", cfg),
    ):
        client.post(
            "/login",
            data={"username": "admin", "password": "secret", "next": "/"},
            follow_redirects=False,
        )
        status = client.get("/auth/status").json()
    assert status["gravatar_url"] == gravatar_url("admin@example.com")
    rec = get_session(client.cookies[ACCESS_TOKEN_COOKIE_NAME])
    assert rec is not None
    assert rec.email == "admin@example.com"


def test_authenticate_local_user_disabled_in_oauth_only_mode() -> None:
    from pygitweb.auth import authenticate_local_user

    with patch("pygitweb.auth.auth_config", _OAUTH_CFG):
        assert authenticate_local_user("admin", "secret") is None


def test_authenticate_local_user_pass_hash() -> None:
    from pygitweb.auth import authenticate_local_user

    cfg = AuthConfig(
        auth_mode="local",
        salt="0123456789abcdef",
        local_users=[LocalUser(user="admin", pass_hash=hash_password("secret", config_salt="0123456789abcdef"))],
    )
    with patch("pygitweb.auth.auth_config", cfg):
        user = authenticate_local_user("admin", "secret")
    assert user is not None
    assert user.username == "admin"
    assert authenticate_local_user("admin", "wrong") is None


def test_exchange_code_success(oauth_auth_config: AuthConfig) -> None:
    resp = _DummyResponse({"access_token": "tok", "other": "x"})

    client = AsyncMock()
    client.post.return_value = resp
    client.__aenter__.return_value = client
    client.__aexit__.return_value = None

    async def runner() -> None:
        with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
            out = await _exchange_code(
                oauth_auth_config,
                code="auth-code",
                code_verifier="verifier",
            )
        assert out["access_token"] == "tok"

    asyncio.run(runner())


def test_exchange_code_rejects_non_mapping(oauth_auth_config: AuthConfig) -> None:
    resp = _DummyResponse(["not-a-dict"])

    client = AsyncMock()
    client.post.return_value = resp
    client.__aenter__.return_value = client
    client.__aexit__.return_value = None

    async def runner() -> None:
        with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
            await _exchange_code(
                oauth_auth_config,
                code="auth-code",
                code_verifier="verifier",
            )

    asyncio.run(runner())


def test_fetch_google_profile_success(oauth_auth_config: AuthConfig) -> None:
    resp = _DummyResponse({"sub": "123", "email": "user@example.com"})

    client = AsyncMock()
    client.get.return_value = resp
    client.__aenter__.return_value = client
    client.__aexit__.return_value = None

    async def runner() -> None:
        with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
            profile = await _fetch_google_profile("access-token")
        assert profile["email"] == "user@example.com"

    asyncio.run(runner())


def test_fetch_google_profile_rejects_non_mapping(oauth_auth_config: AuthConfig) -> None:
    resp = _DummyResponse("not-a-dict")

    client = AsyncMock()
    client.get.return_value = resp
    client.__aenter__.return_value = client
    client.__aexit__.return_value = None

    async def runner() -> None:
        with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
            await _fetch_google_profile("access-token")

    asyncio.run(runner())


def test_fetch_github_profile_uses_primary_verified_email() -> None:
    resp_user = _DummyResponse({"login": "octocat", "email": None})
    resp_emails = _DummyResponse([
        {"email": "secondary@example.com", "primary": False, "verified": True},
        {"email": "primary@example.com", "primary": True, "verified": True},
    ])

    client = AsyncMock()
    client.get.side_effect = (resp_user, resp_emails)
    client.__aenter__.return_value = client
    client.__aexit__.return_value = None

    async def runner() -> None:
        with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
            profile = await _fetch_github_profile("access-token")
        assert profile["email"] == "primary@example.com"

    asyncio.run(runner())


def test_fetch_github_profile_rejects_non_mapping() -> None:
    resp_user = _DummyResponse("not-a-dict")

    client = AsyncMock()
    client.get.return_value = resp_user
    client.__aenter__.return_value = client
    client.__aexit__.return_value = None

    async def runner() -> None:
        with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
            await _fetch_github_profile("access-token")

    asyncio.run(runner())


def test_fetch_profile_google(oauth_auth_config: AuthConfig) -> None:
    token_payload = {"access_token": "tok"}
    profile: dict[str, object] = {"sub": "123"}

    async def runner() -> None:
        with patch("pygitweb.auth_oauth._fetch_google_profile", new_callable=AsyncMock, return_value=profile):
            out = await _fetch_profile(oauth_auth_config, token_payload)
        assert out is profile

    asyncio.run(runner())


def test_fetch_profile_github() -> None:
    cfg = _OAUTH_CFG.model_copy(update={"oauth_provider": "github"})
    token_payload = {"access_token": "tok"}
    profile: dict[str, object] = {"id": "123"}

    async def runner() -> None:
        with patch("pygitweb.auth_oauth._fetch_github_profile", new_callable=AsyncMock, return_value=profile):
            out = await _fetch_profile(cfg, token_payload)
        assert out is profile

    asyncio.run(runner())


def test_fetch_profile_missing_access_token(oauth_auth_config: AuthConfig) -> None:
    async def runner() -> None:
        with pytest.raises(HTTPException):
            await _fetch_profile(oauth_auth_config, {})

    asyncio.run(runner())


def test_fetch_profile_unknown_provider() -> None:
    cfg = _OAUTH_CFG.model_copy(update={"oauth_provider": "other"})

    async def runner() -> None:
        with pytest.raises(HTTPException):
            await _fetch_profile(cfg, {"access_token": "tok"})

    asyncio.run(runner())