# Copyright 2024 The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Tests for scope handling in views."""

from collections.abc import Callable
from typing import Any, ClassVar, cast
from unittest import mock

import django.http
from django.conf import settings
from django.test import RequestFactory
from django.utils.functional import SimpleLazyObject

from debusine.db.context import ContextConsistencyError, context
from debusine.db.models import Scope, User
from debusine.server.middlewares.scopes import (
    AuthorizationMiddleware,
    ScopeMiddleware,
)
from debusine.test.django import TestCase

#: Singleton response used to check if a middleware called get_response()
MOCK_RESPONSE = django.http.HttpResponse()


class MiddlewareTestMixin(TestCase):
    """Common functions to test middlewares."""

    middleware_class: type

    scope: ClassVar[Scope]

    @classmethod
    def setUpTestData(cls):
        """Set up a database layout for views."""
        super().setUpTestData()
        cls.scope = cls.playground.get_or_create_scope("scope")

    def get_middleware(self) -> ScopeMiddleware:
        """Instantiate a test ScopeMiddleware."""
        return self.middleware_class(
            cast(
                Callable[[django.http.HttpRequest], django.http.HttpResponse],
                lambda x: MOCK_RESPONSE,  # noqa: U100
            )
        )

    def request_for_path(
        self, path_info: str, **kwargs: Any
    ) -> django.http.HttpRequest:
        """Configure a request for path_info with the middleware."""
        request = RequestFactory().get(path_info, **kwargs)
        self.get_middleware()(request)
        return request


class ScopeMiddlewareTests(MiddlewareTestMixin):
    """Test ScopeMiddleware."""

    middleware_class = ScopeMiddleware

    def test_get_scope(self) -> None:
        """Test get_scope."""
        default = self.playground.default_scope
        mw = self.get_middleware()
        self.assertEqual(mw.get_scope(settings.DEBUSINE_DEFAULT_SCOPE), default)
        self.assertEqual(mw.get_scope("scope"), self.scope)
        with self.assertRaises(django.http.Http404):
            mw.get_scope("nonexistent")

    def test_request_setup_debusine(self) -> None:
        """Test request setup with the fallback scope."""
        request = self.request_for_path(f"/{settings.DEBUSINE_DEFAULT_SCOPE}/")
        self.assertEqual(getattr(request, "urlconf"), "debusine.project.urls")
        self.assertEqual(context.scope, self.playground.default_scope)

    def test_request_setup_api(self) -> None:
        """Test request setup for API calls."""
        request = self.request_for_path("/api")
        self.assertEqual(getattr(request, "urlconf"), "debusine.project.urls")
        self.assertEqual(context.scope, self.playground.default_scope)

    def test_request_setup_api_scope_in_header(self) -> None:
        """Test request setup for API calls with scope in header."""
        # TODO: use headers={"X-Debusine-Scope": "scope"} from Django 4.2+
        request = self.request_for_path("/api", HTTP_X_DEBUSINE_SCOPE="scope")
        self.assertEqual(
            getattr(request, "urlconf"), "debusine.server._urlconfs.scope"
        )
        self.assertEqual(context.scope, self.scope)

    def test_request_setup_scope(self) -> None:
        """Test request setup with a valid scope."""
        request = self.request_for_path("/scope/")
        self.assertEqual(
            getattr(request, "urlconf"), "debusine.server._urlconfs.scope"
        )
        self.assertEqual(context.scope, self.scope)

    def test_request_setup_wrong_scope(self) -> None:
        """Test request setup with an invalid scope."""
        with self.assertRaises(django.http.Http404):
            self.request_for_path("/wrongscope/")


class AuthorizationMiddlewareTests(MiddlewareTestMixin):
    """Test AuthorizationMiddleware."""

    middleware_class = AuthorizationMiddleware

    def test_allowed(self) -> None:
        """Test the allowed case."""
        # There currently is no permission predicate implemented for scope
        # visibility, only a comment placeholder in Context.set_user.
        context.set_scope(self.scope)
        mw = self.get_middleware()
        request = RequestFactory().get("/")
        request.user = self.playground.get_default_user()
        self.assertIs(mw(request), MOCK_RESPONSE)
        self.assertEqual(context.user, self.playground.get_default_user())

    def test_forbidden(self) -> None:
        """Test the forbidden case."""
        # There currently is no permission predicate implemented for scope
        # visibility, only a comment placeholder in Context.set_user.
        # Mock Context.set_user instead of a permission, to test handling of
        # failure to set it
        context.set_scope(self.scope)
        mw = self.get_middleware()
        request = RequestFactory().get("/")
        request.user = self.playground.get_default_user()
        with mock.patch(
            "debusine.db.context.Context.set_user",
            side_effect=ContextConsistencyError("expected fail"),
        ):
            response = mw(request)

        self.assertEqual(response.status_code, 403)
        self.assertEqual(response.content, b"expected fail")
        self.assertIsNone(context.user)

    def test_evaluates_lazy_object(self) -> None:
        """Lazy objects are evaluated before storing them in the context."""
        context.set_scope(self.scope)
        mw = self.get_middleware()
        request = RequestFactory().get("/")
        request.user = SimpleLazyObject(
            self.playground.get_default_user
        )  # type: ignore[assignment]
        self.assertIs(mw(request), MOCK_RESPONSE)
        default_user = self.playground.get_default_user()
        with mock.patch.object(User.objects, "get", side_effect=RuntimeError):
            self.assertEqual(context.user, default_user)
