mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-25 09:08:55 +00:00
361 lines
13 KiB
Python
361 lines
13 KiB
Python
import logging
|
|
|
|
import pytest
|
|
from allauth.account.adapter import get_adapter
|
|
from allauth.account.models import Login
|
|
from allauth.core import context
|
|
from allauth.mfa.totp.internal.auth import TOTP
|
|
from allauth.mfa.totp.internal.auth import generate_totp_secret
|
|
from allauth.socialaccount.adapter import get_adapter as get_social_adapter
|
|
from allauth.socialaccount.models import SocialAccount
|
|
from allauth.socialaccount.models import SocialLogin
|
|
from django.contrib.auth.models import AnonymousUser
|
|
from django.contrib.auth.models import Group
|
|
from django.contrib.auth.models import User
|
|
from django.forms import ValidationError
|
|
from django.http import HttpRequest
|
|
from django.test import RequestFactory
|
|
from django.urls import reverse
|
|
from pytest_django.fixtures import Settings
|
|
from pytest_mock import MockerFixture
|
|
from rest_framework.authtoken.models import Token
|
|
|
|
from paperless.adapter import DrfTokenStrategy
|
|
from paperless.adapter import SocialAccountAuthenticateStage
|
|
from paperless_testing.factories import UserFactory
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestCustomAccountAdapter:
|
|
def test_is_open_for_signup(self, settings: Settings) -> None:
|
|
adapter = get_adapter()
|
|
|
|
# With no accounts, signups should be allowed
|
|
assert adapter.is_open_for_signup(None)
|
|
|
|
UserFactory(username="testuser")
|
|
|
|
settings.ACCOUNT_ALLOW_SIGNUPS = True
|
|
assert adapter.is_open_for_signup(None)
|
|
|
|
settings.ACCOUNT_ALLOW_SIGNUPS = False
|
|
assert not adapter.is_open_for_signup(None)
|
|
|
|
def test_is_safe_url(self, settings: Settings) -> None:
|
|
request = HttpRequest()
|
|
request.get_host = lambda: "example.com"
|
|
with context.request_context(request):
|
|
adapter = get_adapter()
|
|
|
|
settings.ALLOWED_HOSTS = ["*"]
|
|
# True because request host is same
|
|
assert adapter.is_safe_url("https://example.com")
|
|
# False despite wildcard because request host is different
|
|
assert not adapter.is_safe_url("https://evil.com")
|
|
|
|
settings.ALLOWED_HOSTS = ["example.com"]
|
|
# True because request host is same
|
|
assert adapter.is_safe_url("https://example.com")
|
|
|
|
settings.ALLOWED_HOSTS = ["*", "example.com"]
|
|
# False because request host is not in allowed hosts
|
|
assert not adapter.is_safe_url("//evil.com")
|
|
|
|
def test_pre_authenticate(
|
|
self,
|
|
settings: Settings,
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
mocker.patch("allauth.core.internal.ratelimit.consume", return_value=True)
|
|
adapter = get_adapter()
|
|
request = HttpRequest()
|
|
request.get_host = lambda: "example.com"
|
|
|
|
settings.DISABLE_REGULAR_LOGIN = False
|
|
adapter.pre_authenticate(request)
|
|
|
|
settings.DISABLE_REGULAR_LOGIN = True
|
|
with pytest.raises(ValidationError):
|
|
adapter.pre_authenticate(request)
|
|
|
|
def test_get_reset_password_from_key_url(self, settings: Settings) -> None:
|
|
request = HttpRequest()
|
|
request.get_host = lambda: "foo.org"
|
|
with context.request_context(request):
|
|
adapter = get_adapter()
|
|
|
|
settings.PAPERLESS_URL = None
|
|
settings.ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https"
|
|
expected_url = f"https://foo.org{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
|
|
assert adapter.get_reset_password_from_key_url("UID-KEY") == expected_url
|
|
|
|
settings.PAPERLESS_URL = "https://bar.com"
|
|
expected_url = f"https://bar.com{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
|
|
assert adapter.get_reset_password_from_key_url("UID-KEY") == expected_url
|
|
|
|
def test_save_user_adds_groups(
|
|
self,
|
|
settings: Settings,
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
settings.ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"]
|
|
Group.objects.create(name="group1")
|
|
user = UserFactory(username="testuser")
|
|
adapter = get_adapter()
|
|
form = mocker.MagicMock(
|
|
cleaned_data={
|
|
"username": "testuser",
|
|
"email": "user@example.com",
|
|
},
|
|
)
|
|
|
|
user = adapter.save_user(HttpRequest(), user, form, commit=True)
|
|
|
|
assert user.groups.count() == 1
|
|
assert user.groups.filter(name="group1").exists()
|
|
assert not user.groups.filter(name="group2").exists()
|
|
|
|
def test_fresh_install_save_creates_superuser(self, mocker: MockerFixture) -> None:
|
|
adapter = get_adapter()
|
|
form = mocker.MagicMock(
|
|
cleaned_data={
|
|
"username": "testuser",
|
|
"email": "user@paperless-ngx.com",
|
|
},
|
|
)
|
|
user = adapter.save_user(HttpRequest(), User(), form, commit=True)
|
|
assert user.is_superuser
|
|
|
|
form = mocker.MagicMock(
|
|
cleaned_data={
|
|
"username": "testuser2",
|
|
"email": "user2@paperless-ngx.com",
|
|
},
|
|
)
|
|
user2 = adapter.save_user(HttpRequest(), User(), form, commit=True)
|
|
assert not user2.is_superuser
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestSocialAccountAuthenticateStage:
|
|
@staticmethod
|
|
def _requires_mfa(user: User, extra_data: dict | None) -> bool:
|
|
signal_kwargs = None
|
|
if extra_data is not None:
|
|
account = SocialAccount(
|
|
provider="openid_connect",
|
|
uid="uid",
|
|
extra_data=extra_data,
|
|
)
|
|
signal_kwargs = {"sociallogin": SocialLogin(user=user, account=account)}
|
|
request = RequestFactory().get("/")
|
|
request.session = {}
|
|
stage = SocialAccountAuthenticateStage(
|
|
None,
|
|
request,
|
|
Login(user=user, signal_kwargs=signal_kwargs),
|
|
)
|
|
response, cont = stage.handle()
|
|
assert cont is True
|
|
requires_mfa = response is not None
|
|
assert stage.state.get("authentication_required", False) is requires_mfa
|
|
if requires_mfa:
|
|
assert response.status_code == 302
|
|
assert response.url == reverse("mfa_authenticate")
|
|
return requires_mfa
|
|
|
|
def test_stage_replaces_allauth_stage(self) -> None:
|
|
stages = get_adapter().get_login_stages()
|
|
|
|
assert "paperless.adapter.SocialAccountAuthenticateStage" in stages
|
|
assert "allauth.mfa.stages.AuthenticateStage" not in stages
|
|
|
|
@pytest.mark.parametrize(
|
|
("trusted_amr", "extra_data", "expected"),
|
|
[
|
|
pytest.param(
|
|
["mfa"],
|
|
{"id_token": {"amr": ["pwd", "mfa"]}},
|
|
False,
|
|
id="trusted-amr",
|
|
),
|
|
pytest.param(
|
|
["phr"],
|
|
{"id_token": {"amr": ["otp"]}},
|
|
True,
|
|
id="untrusted-amr",
|
|
),
|
|
pytest.param([], {"id_token": {"amr": ["mfa"]}}, True, id="setting-unset"),
|
|
pytest.param(["mfa"], {"id_token": {}}, True, id="no-amr-claim"),
|
|
pytest.param(
|
|
["mfa"],
|
|
{"userinfo": {"amr": ["mfa"]}},
|
|
True,
|
|
id="userinfo-only",
|
|
),
|
|
pytest.param(["m"], {"id_token": {"amr": "mfa"}}, True, id="string-amr"),
|
|
pytest.param(
|
|
["mfa"],
|
|
{"id_token": {"amr": {"mfa": 1}}},
|
|
True,
|
|
id="dict-amr",
|
|
),
|
|
pytest.param(
|
|
["mfa"],
|
|
{"id_token": {"amr": [["mfa"]]}},
|
|
True,
|
|
id="nested-amr",
|
|
),
|
|
pytest.param(
|
|
["mfa"],
|
|
{"id_token": {"amr": ["mfa", 1]}},
|
|
True,
|
|
id="mixed-amr",
|
|
),
|
|
pytest.param(["mfa"], {"id_token": {"amr": None}}, True, id="null-amr"),
|
|
pytest.param(["mfa"], {"id_token": {"amr": []}}, True, id="empty-amr"),
|
|
pytest.param(["mfa"], {"id_token": "mfa"}, True, id="string-id-token"),
|
|
pytest.param(["mfa"], {"id_token": ["mfa"]}, True, id="list-id-token"),
|
|
pytest.param(["mfa"], {"id_token": None}, True, id="null-id-token"),
|
|
pytest.param(["mfa"], None, True, id="regular-login"),
|
|
],
|
|
)
|
|
def test_mfa_skipped_only_for_trusted_amr(
|
|
self,
|
|
settings: Settings,
|
|
trusted_amr: list[str],
|
|
extra_data: dict | None,
|
|
*,
|
|
expected: bool,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A user with TOTP enabled
|
|
WHEN:
|
|
- The user logs in, via a social account or not
|
|
THEN:
|
|
- The 2FA prompt is only skipped if the ID token's amr claim contains
|
|
one of SOCIAL_ACCOUNT_MFA_TRUSTED_AMR
|
|
"""
|
|
settings.SOCIAL_ACCOUNT_MFA_TRUSTED_AMR = trusted_amr
|
|
user = UserFactory(username="testuser")
|
|
TOTP.activate(user, generate_totp_secret())
|
|
|
|
assert self._requires_mfa(user, extra_data) is expected
|
|
|
|
def test_no_mfa_for_user_without_authenticator(self, settings: Settings) -> None:
|
|
settings.SOCIAL_ACCOUNT_MFA_TRUSTED_AMR = ["mfa"]
|
|
user = UserFactory(username="testuser")
|
|
|
|
assert not self._requires_mfa(user, {"id_token": {"amr": ["pwd"]}})
|
|
|
|
|
|
class TestCustomSocialAccountAdapter:
|
|
@pytest.mark.django_db
|
|
def test_is_open_for_signup(self, settings: Settings) -> None:
|
|
adapter = get_social_adapter()
|
|
|
|
settings.SOCIALACCOUNT_ALLOW_SIGNUPS = True
|
|
assert adapter.is_open_for_signup(None, None)
|
|
|
|
settings.SOCIALACCOUNT_ALLOW_SIGNUPS = False
|
|
assert not adapter.is_open_for_signup(None, None)
|
|
|
|
def test_get_connect_redirect_url(self) -> None:
|
|
adapter = get_social_adapter()
|
|
assert adapter.get_connect_redirect_url(None, None) == reverse("base")
|
|
|
|
@pytest.mark.django_db
|
|
def test_save_user_adds_groups(
|
|
self,
|
|
settings: Settings,
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
settings.SOCIAL_ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"]
|
|
Group.objects.create(name="group1")
|
|
adapter = get_social_adapter()
|
|
user = UserFactory(username="testuser")
|
|
sociallogin = mocker.MagicMock(user=user)
|
|
|
|
user = adapter.save_user(HttpRequest(), sociallogin, None)
|
|
|
|
assert user.groups.count() == 1
|
|
assert user.groups.filter(name="group1").exists()
|
|
assert not user.groups.filter(name="group2").exists()
|
|
|
|
def test_error_logged_on_authentication_error(
|
|
self,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
adapter = get_social_adapter()
|
|
with caplog.at_level(logging.INFO, logger="paperless.auth"):
|
|
adapter.on_authentication_error(
|
|
HttpRequest(),
|
|
provider="test-provider",
|
|
error="Error",
|
|
exception="Test authentication error",
|
|
)
|
|
assert any("Test authentication error" in msg for msg in caplog.messages)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestDrfTokenStrategy:
|
|
def test_create_access_token_creates_new_token(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A user with no existing DRF token
|
|
WHEN:
|
|
- create_access_token is called
|
|
THEN:
|
|
- A new token is created and its key is returned
|
|
"""
|
|
user = UserFactory(username="testuser")
|
|
request = HttpRequest()
|
|
request.user = user
|
|
|
|
strategy = DrfTokenStrategy()
|
|
token_key = strategy.create_access_token(request)
|
|
|
|
assert token_key is not None
|
|
assert Token.objects.filter(user=user).exists()
|
|
assert token_key == Token.objects.get(user=user).key
|
|
|
|
def test_create_access_token_returns_existing_token(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A user with an existing DRF token
|
|
WHEN:
|
|
- create_access_token is called again
|
|
THEN:
|
|
- The same token key is returned (no new token created)
|
|
"""
|
|
user = UserFactory(username="testuser")
|
|
existing_token = Token.objects.create(user=user)
|
|
|
|
request = HttpRequest()
|
|
request.user = user
|
|
|
|
strategy = DrfTokenStrategy()
|
|
token_key = strategy.create_access_token(request)
|
|
|
|
assert token_key == existing_token.key
|
|
assert Token.objects.filter(user=user).count() == 1
|
|
|
|
def test_create_access_token_returns_none_for_unauthenticated_user(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An unauthenticated request
|
|
WHEN:
|
|
- create_access_token is called
|
|
THEN:
|
|
- None is returned and no token is created
|
|
"""
|
|
request = HttpRequest()
|
|
request.user = AnonymousUser()
|
|
|
|
strategy = DrfTokenStrategy()
|
|
token_key = strategy.create_access_token(request)
|
|
|
|
assert token_key is None
|
|
assert Token.objects.count() == 0
|