Compare commits

...
5 Commits
Author SHA1 Message Date
GitHub Actions a19804a0b7 Auto translate strings 2026-10-31 16:34:17 +00:00
Trenton Handshamoon 3289be7383 Fix: Various small bugs found during typing updates (#14593)
* Fix: match Apple Mail tag colors case-insensitively

* Fix: reject OAuth callbacks with an unrecognized scope

* Fix: refuse to refresh OAuth tokens for non-OAuth accounts or missing refresh tokens

* Fix: parse datetime strings in the format_datetime filter and reject invalid ones

* Fix: reject a PAPERLESS_URL without a hostname

* Fix: report a database error in the system status instead of failing with a 500

* Fix: surface the database error when refreshing a document before moving its files

* Fix: report no unapplied migrations when the applied migrations cannot be read

* Fix: return the parsed value directly in format_datetime for typing

* Chore: annotate the paperless_dirs fixture in the refresh failure test

* Allow null for the latest_migration system status type

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-10-31 09:32:58 -07:00
GitHub Actions d98918dd97 Auto translate strings 2026-10-31 06:13:00 +00:00
shamoon 34c88613f6 Fix: fix custom fields dropdown text size (#14606) 2026-10-30 23:11:45 -07:00
github-actions[bot] 13acdabb47 Changelog v3.4.1 - GHA (#14605)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-30 22:44:05 -07:00
18 changed files with 300 additions and 51 deletions

No files matched your search

+24
View File
@@ -1,5 +1,29 @@
# Changelog # Changelog
## paperless-ngx 3.4.1
### Bug Fixes
- Fix the id\_\_ schemae in the right place [@shamoon](https://github.com/shamoon) ([#14604](https://github.com/paperless-ngx/paperless-ngx/pull/14604))
- Fix: drop redundant change perms checks on cards open button [@shamoon](https://github.com/shamoon) ([#14601](https://github.com/paperless-ngx/paperless-ngx/pull/14601))
- Tweak/fix: make new pdf controls mostly hidden [@shamoon](https://github.com/shamoon) ([#14595](https://github.com/paperless-ngx/paperless-ngx/pull/14595))
- Tweak/fix: move preview button in doc list [@shamoon](https://github.com/shamoon) ([#14594](https://github.com/paperless-ngx/paperless-ngx/pull/14594))
- Fix: checksum light mode display and cf dropdown mobile position [@shamoon](https://github.com/shamoon) ([#14590](https://github.com/paperless-ngx/paperless-ngx/pull/14590))
### All App Changes
<details>
<summary>6 changes</summary>
- Fix the id\_\_ schemae in the right place [@shamoon](https://github.com/shamoon) ([#14604](https://github.com/paperless-ngx/paperless-ngx/pull/14604))
- Fix: drop redundant change perms checks on cards open button [@shamoon](https://github.com/shamoon) ([#14601](https://github.com/paperless-ngx/paperless-ngx/pull/14601))
- Chore: Improve idle memory via more lazy imports [@stumpylog](https://github.com/stumpylog) ([#14599](https://github.com/paperless-ngx/paperless-ngx/pull/14599))
- Tweak/fix: make new pdf controls mostly hidden [@shamoon](https://github.com/shamoon) ([#14595](https://github.com/paperless-ngx/paperless-ngx/pull/14595))
- Tweak/fix: move preview button in doc list [@shamoon](https://github.com/shamoon) ([#14594](https://github.com/paperless-ngx/paperless-ngx/pull/14594))
- Fix: checksum light mode display and cf dropdown mobile position [@shamoon](https://github.com/shamoon) ([#14590](https://github.com/paperless-ngx/paperless-ngx/pull/14590))
</details>
## paperless-ngx 3.4.0 ## paperless-ngx 3.4.0
### Features / Enhancements ### Features / Enhancements
+1 -1
View File
@@ -4389,7 +4389,7 @@
<source>Create new field</source> <source>Create new field</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context>
<context context-type="linenumber">20</context> <context context-type="linenumber">19</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6973528734330066202" datatype="html"> <trans-unit id="6973528734330066202" datatype="html">
@@ -11,14 +11,12 @@
</div> </div>
@for (field of filteredFields; track field.id) { @for (field of filteredFields; track field.id) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addField(field)" #button> <button type="button" class="list-group-item list-group-item-action bg-light" (click)="addField(field)" #button>
<small class="d-flex">{{field.name}} <small class="ms-auto text-muted">{{getDataTypeLabel(field.data_type)}}</small></small> <div class="d-flex">{{field.name}} <small class="ms-auto text-muted">{{getDataTypeLabel(field.data_type)}}</small></div>
</button> </button>
} }
@if (!filterText?.length || filteredFields.length === 0) { @if (!filterText?.length || filteredFields.length === 0) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="createField(filterText)" [disabled]="!canCreateFields" #button> <button type="button" class="list-group-item list-group-item-action bg-light" (click)="createField(filterText)" [disabled]="!canCreateFields" #button>
<small>
<i-bs width=".9em" height=".9em" name="asterisk" class="me-1"></i-bs><ng-container i18n>Create new field</ng-container> <i-bs width=".9em" height=".9em" name="asterisk" class="me-1"></i-bs><ng-container i18n>Create new field</ng-container>
</small>
</button> </button>
} }
</div> </div>
+1 -1
View File
@@ -24,7 +24,7 @@ export interface SystemStatus {
status: SystemStatusItemStatus status: SystemStatusItemStatus
error?: string error?: string
migration_status: { migration_status: {
latest_migration: string latest_migration: string | null
unapplied_migrations: string[] unapplied_migrations: string[]
} }
} }
+1 -1
View File
@@ -476,13 +476,13 @@ def update_filename_and_move_files(
return return
with FileLock(settings.MEDIA_LOCK): with FileLock(settings.MEDIA_LOCK):
try:
# If this was waiting for the lock, the filename or archive_filename # If this was waiting for the lock, the filename or archive_filename
# of this document may have been updated. This happens if multiple updates # of this document may have been updated. This happens if multiple updates
# get queued from the UI for the same document # get queued from the UI for the same document
# So freshen up the data before doing anything # So freshen up the data before doing anything
instance.refresh_from_db() instance.refresh_from_db()
try:
old_filename = instance.filename old_filename = instance.filename
old_source_path = instance.source_path old_source_path = instance.source_path
move_original = False move_original = False
+4 -1
View File
@@ -44,7 +44,10 @@ def localize_date(value: date | datetime | str, format: str, locale: str) -> str
def format_datetime(value: str | datetime, format: str) -> str: def format_datetime(value: str | datetime, format: str) -> str:
if isinstance(value, str): if isinstance(value, str):
value = parse_date(value) parsed = parse_datetime(value) or parse_date(value)
if parsed is None:
raise ValueError(f"Invalid date or datetime string: {value!r}")
return parsed.strftime(format=format)
return value.strftime(format=format) return value.strftime(format=format)
+47
View File
@@ -539,3 +539,50 @@ class TestSystemStatus(APITestCase):
self.assertEqual(response.data["tasks"]["summary"]["success_count"], 1) self.assertEqual(response.data["tasks"]["summary"]["success_count"], 1)
self.assertEqual(response.data["tasks"]["summary"]["failure_count"], 1) self.assertEqual(response.data["tasks"]["summary"]["failure_count"], 1)
self.assertEqual(response.data["tasks"]["summary"]["pending_count"], 1) self.assertEqual(response.data["tasks"]["summary"]["pending_count"], 1)
def test_system_status_reports_database_error(self) -> None:
"""
GIVEN:
- Reading the migration state fails
WHEN:
- The system status is requested
THEN:
- The response reports the database error rather than a server error
"""
self.client.force_login(self.user)
with mock.patch(
"documents.views.MigrationLoader",
side_effect=Exception("database is down"),
):
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
database = response.data["database"]
self.assertEqual(database["status"], "ERROR")
self.assertIsNotNone(database["error"])
self.assertEqual(
database["migration_status"],
{"latest_migration": None, "unapplied_migrations": []},
)
def test_system_status_migration_query_error(self) -> None:
"""
GIVEN:
- The migration graph loads but reading the applied migrations fails
WHEN:
- The system status is requested
THEN:
- No migrations are reported as unapplied, since the state is unknown
"""
self.client.force_login(self.user)
with mock.patch("documents.views.MigrationRecorder") as recorder:
recorder.Migration.objects.all.side_effect = Exception("query failed")
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
database = response.data["database"]
self.assertEqual(database["status"], "ERROR")
self.assertEqual(
database["migration_status"],
{"latest_migration": None, "unapplied_migrations": []},
)
+26
View File
@@ -31,6 +31,7 @@ from documents.serialisers import DocumentSerializer
from documents.tasks import empty_trash from documents.tasks import empty_trash
from paperless_testing.assertions import FileSystemAssertsMixin from paperless_testing.assertions import FileSystemAssertsMixin
from paperless_testing.dirs import DirectoriesMixin from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.dirs import PaperlessDirs
from paperless_testing.factories import DocumentFactory from paperless_testing.factories import DocumentFactory
from paperless_testing.factories import UserFactory from paperless_testing.factories import UserFactory
@@ -2134,3 +2135,28 @@ class TestPathDateLocalization:
with override_settings(FILENAME_FORMAT=filename_format): with override_settings(FILENAME_FORMAT=filename_format):
filename = generate_filename(document) filename = generate_filename(document)
assert filename == Path(expected_filename) assert filename == Path(expected_filename)
@pytest.mark.django_db
def test_refresh_failure_surfaces_database_error(
paperless_dirs: PaperlessDirs,
) -> None:
"""
GIVEN:
- A document with a stored filename
WHEN:
- Saving it triggers a filename update and refreshing it from the database fails
THEN:
- The database error is raised, not an unbound variable error from the rollback
"""
document = DocumentFactory(filename="some.pdf")
with (
mock.patch.object(
Document,
"refresh_from_db",
side_effect=DatabaseError("database is locked"),
),
pytest.raises(DatabaseError, match="database is locked"),
):
document.save()
+21
View File
@@ -4,6 +4,7 @@ from typing import Literal
import pytest import pytest
from documents.templating.filters import format_datetime
from documents.templating.filters import localize_date from documents.templating.filters import localize_date
@@ -294,3 +295,23 @@ class TestDateLocalization:
Tests `localize_date` with `date` string across different locales and formats. Tests `localize_date` with `date` string across different locales and formats.
""" """
assert localize_date(value, format_style, locale_str) == expected_output assert localize_date(value, format_style, locale_str) == expected_output
class TestFormatDatetime:
@pytest.mark.parametrize(
("value", "expected"),
[
pytest.param("2026-09-21", "2026-09-21 00:00", id="date"),
pytest.param(
"2026-09-21T13:45:00+00:00",
"2026-09-21 13:45",
id="datetime",
),
],
)
def test_parses_iso_strings(self, value: str, expected: str) -> None:
assert format_datetime(value, "%Y-%m-%d %H:%M") == expected
def test_rejects_unparseable_string(self) -> None:
with pytest.raises(ValueError, match="not a date"):
format_datetime("not a date", "%Y")
+10 -3
View File
@@ -5268,7 +5268,9 @@ class CustomFieldViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Custom
"migration_status": inline_serializer( "migration_status": inline_serializer(
name="MigrationStatus", name="MigrationStatus",
fields={ fields={
"latest_migration": serializers.CharField(), "latest_migration": serializers.CharField(
allow_null=True,
),
"unapplied_migrations": serializers.ListSerializer( "unapplied_migrations": serializers.ListSerializer(
child=serializers.CharField(), child=serializers.CharField(),
), ),
@@ -5343,6 +5345,8 @@ class SystemStatusView(PassUserMixin):
db_conn = connections["default"] db_conn = connections["default"]
db_url = str(db_conn.settings_dict["NAME"]) db_url = str(db_conn.settings_dict["NAME"])
db_error = None db_error = None
all_migrations: list[str] = []
applied_migrations: list[str] = []
try: try:
db_conn.ensure_connection() db_conn.ensure_connection()
@@ -5353,7 +5357,8 @@ class SystemStatusView(PassUserMixin):
f"{m.app}.{m.name}" f"{m.app}.{m.name}"
for m in MigrationRecorder.Migration.objects.all().order_by("id") for m in MigrationRecorder.Migration.objects.all().order_by("id")
] ]
except Exception as e: # pragma: no cover except Exception as e:
all_migrations = []
applied_migrations = [] applied_migrations = []
db_status = "ERROR" db_status = "ERROR"
logger.exception( logger.exception(
@@ -5549,7 +5554,9 @@ class SystemStatusView(PassUserMixin):
"status": db_status, "status": db_status,
"error": db_error, "error": db_error,
"migration_status": { "migration_status": {
"latest_migration": applied_migrations[-1], "latest_migration": (
applied_migrations[-1] if applied_migrations else None
),
"unapplied_migrations": [ "unapplied_migrations": [
m for m in all_migrations if m not in applied_migrations m for m in all_migrations if m not in applied_migrations
], ],
+38 -38
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-31 04:47+0000\n" "POT-Creation-Date: 2026-10-31 16:33+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n" "PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: English\n" "Language-Team: English\n"
@@ -2274,151 +2274,151 @@ msgstr ""
msgid "paperless application settings" msgid "paperless application settings"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:566 #: paperless/settings/__init__.py:571
msgid "English (US)" msgid "English (US)"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:567 #: paperless/settings/__init__.py:572
msgid "Arabic" msgid "Arabic"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:568 #: paperless/settings/__init__.py:573
msgid "Afrikaans" msgid "Afrikaans"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:569 #: paperless/settings/__init__.py:574
msgid "Belarusian" msgid "Belarusian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:570 #: paperless/settings/__init__.py:575
msgid "Bulgarian" msgid "Bulgarian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:571 #: paperless/settings/__init__.py:576
msgid "Catalan" msgid "Catalan"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:572 #: paperless/settings/__init__.py:577
msgid "Czech" msgid "Czech"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:573 #: paperless/settings/__init__.py:578
msgid "Danish" msgid "Danish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:574 #: paperless/settings/__init__.py:579
msgid "German" msgid "German"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:575 #: paperless/settings/__init__.py:580
msgid "Greek" msgid "Greek"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:576 #: paperless/settings/__init__.py:581
msgid "English (GB)" msgid "English (GB)"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:577 #: paperless/settings/__init__.py:582
msgid "Spanish" msgid "Spanish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:578 #: paperless/settings/__init__.py:583
msgid "Persian" msgid "Persian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:579 #: paperless/settings/__init__.py:584
msgid "Finnish" msgid "Finnish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:580 #: paperless/settings/__init__.py:585
msgid "French" msgid "French"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:581 #: paperless/settings/__init__.py:586
msgid "Hungarian" msgid "Hungarian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:582 #: paperless/settings/__init__.py:587
msgid "Indonesian" msgid "Indonesian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:583 #: paperless/settings/__init__.py:588
msgid "Italian" msgid "Italian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:584 #: paperless/settings/__init__.py:589
msgid "Japanese" msgid "Japanese"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:585 #: paperless/settings/__init__.py:590
msgid "Korean" msgid "Korean"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:586 #: paperless/settings/__init__.py:591
msgid "Luxembourgish" msgid "Luxembourgish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:587 #: paperless/settings/__init__.py:592
msgid "Norwegian" msgid "Norwegian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:588 #: paperless/settings/__init__.py:593
msgid "Dutch" msgid "Dutch"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:589 #: paperless/settings/__init__.py:594
msgid "Polish" msgid "Polish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:590 #: paperless/settings/__init__.py:595
msgid "Portuguese (Brazil)" msgid "Portuguese (Brazil)"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:591 #: paperless/settings/__init__.py:596
msgid "Portuguese" msgid "Portuguese"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:592 #: paperless/settings/__init__.py:597
msgid "Romanian" msgid "Romanian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:593 #: paperless/settings/__init__.py:598
msgid "Russian" msgid "Russian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:594 #: paperless/settings/__init__.py:599
msgid "Slovak" msgid "Slovak"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:595 #: paperless/settings/__init__.py:600
msgid "Slovenian" msgid "Slovenian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:596 #: paperless/settings/__init__.py:601
msgid "Serbian" msgid "Serbian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:597 #: paperless/settings/__init__.py:602
msgid "Swedish" msgid "Swedish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:598 #: paperless/settings/__init__.py:603
msgid "Turkish" msgid "Turkish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:599 #: paperless/settings/__init__.py:604
msgid "Ukrainian" msgid "Ukrainian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:600 #: paperless/settings/__init__.py:605
msgid "Vietnamese" msgid "Vietnamese"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:601 #: paperless/settings/__init__.py:606
msgid "Chinese Simplified" msgid "Chinese Simplified"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:602 #: paperless/settings/__init__.py:607
msgid "Chinese Traditional" msgid "Chinese Traditional"
msgstr "" msgstr ""
+6 -1
View File
@@ -461,9 +461,14 @@ def _parse_paperless_url():
global CSRF_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS, ALLOWED_HOSTS global CSRF_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS, ALLOWED_HOSTS
url = os.getenv("PAPERLESS_URL") url = os.getenv("PAPERLESS_URL")
if url: if url:
hostname = urlparse(url).hostname
if not hostname:
raise ImproperlyConfigured(
f"PAPERLESS_URL {url!r} has no hostname, expected a URL like https://example.com",
)
CSRF_TRUSTED_ORIGINS.append(url) CSRF_TRUSTED_ORIGINS.append(url)
CORS_ALLOWED_ORIGINS.append(url) CORS_ALLOWED_ORIGINS.append(url)
ALLOWED_HOSTS.append(urlparse(url).hostname) ALLOWED_HOSTS.append(hostname)
return url return url
@@ -166,3 +166,22 @@ class TestPaperlessURLSettings(TestCase):
self.assertIn(url, settings.CSRF_TRUSTED_ORIGINS) self.assertIn(url, settings.CSRF_TRUSTED_ORIGINS)
self.assertIn(url, settings.CORS_ALLOWED_ORIGINS) self.assertIn(url, settings.CORS_ALLOWED_ORIGINS)
@pytest.mark.parametrize("url", ["example.com", "https://", "/just/a/path"])
def test_paperless_url_without_hostname_is_rejected(
monkeypatch: pytest.MonkeyPatch,
url: str,
) -> None:
"""
GIVEN:
- PAPERLESS_URL is set to a value with no hostname
WHEN:
- The URL is parsed
THEN:
- A configuration error is raised instead of adding None to ALLOWED_HOSTS
"""
monkeypatch.setenv("PAPERLESS_URL", url)
with pytest.raises(ImproperlyConfigured, match="no hostname"):
_parse_paperless_url()
+2 -2
View File
@@ -169,9 +169,9 @@ class TagMailAction(BaseMailAction):
# The custom tag should look like "apple:<color>" # The custom tag should look like "apple:<color>"
if "apple:" in parameter.lower(): if "apple:" in parameter.lower():
_, self.color = parameter.split(":") _, self.color = parameter.split(":")
self.color = self.color.strip() self.color = self.color.strip().lower()
if self.color.lower() not in APPLE_MAIL_TAG_COLORS: if self.color not in APPLE_MAIL_TAG_COLORS:
raise MailError("Not a valid AppleMail tag color.") raise MailError("Not a valid AppleMail tag color.")
self.keyword = None self.keyword = None
+6
View File
@@ -89,6 +89,9 @@ class PaperlessMailOAuth2Manager:
""" """
logger = logging.getLogger("paperless_mail") logger = logging.getLogger("paperless_mail")
logger.debug(f"Attempting to refresh oauth token for account {account}") logger.debug(f"Attempting to refresh oauth token for account {account}")
if account.refresh_token is None:
logger.error(f"Account {account} has no refresh token to use")
return False
try: try:
result: OAuth2Token result: OAuth2Token
if account.account_type == MailAccount.MailAccountType.GMAIL_OAUTH: if account.account_type == MailAccount.MailAccountType.GMAIL_OAUTH:
@@ -103,6 +106,9 @@ class PaperlessMailOAuth2Manager:
refresh_token=account.refresh_token, refresh_token=account.refresh_token,
), ),
) )
else:
logger.error(f"Account {account} does not use OAuth")
return False
if "refresh_token" in result: if "refresh_token" in result:
# Outlook returns a new refresh token on refresh, Gmail does not # Outlook returns a new refresh token on refresh, Gmail does not
account.refresh_token = result["refresh_token"] account.refresh_token = result["refresh_token"]
+27
View File
@@ -23,6 +23,7 @@ from rest_framework.test import APITestCase
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import MatchingModel from documents.models import MatchingModel
from paperless_mail import tasks from paperless_mail import tasks
from paperless_mail.mail import APPLE_MAIL_TAG_COLORS
from paperless_mail.mail import MailAccountHandler from paperless_mail.mail import MailAccountHandler
from paperless_mail.mail import MailError from paperless_mail.mail import MailError
from paperless_mail.mail import TagMailAction from paperless_mail.mail import TagMailAction
@@ -1873,6 +1874,32 @@ class TestErrorCallback:
assert processed_mails.get().owner == owner assert processed_mails.get().owner == owner
class TestTagMailActionColor:
@pytest.mark.parametrize(
"parameter",
["apple:green", "apple:Green", "APPLE: GREEN "],
)
def test_color_case_is_ignored(self, parameter: str) -> None:
"""
GIVEN:
- An Apple Mail tag parameter in any letter case
WHEN:
- The action tags a message
THEN:
- The lowercase color's flag bits are set on the message
"""
action = TagMailAction(parameter, supports_gmail_labels=False)
mailbox = mock.Mock()
action.post_consume(mailbox, "1", parameter)
assert mailbox.flag.call_args_list[1] == mock.call(
"1",
APPLE_MAIL_TAG_COLORS["green"],
value=True,
)
class TestManagementCommand(TestCase): class TestManagementCommand(TestCase):
@mock.patch( @mock.patch(
"paperless_mail.management.commands.mail_fetcher.tasks.process_mail_accounts", "paperless_mail.management.commands.mail_fetcher.tasks.process_mail_accounts",
@@ -264,6 +264,28 @@ class TestMailOAuth(
MailAccount.objects.filter(imap_server="outlook.office365.com").exists(), MailAccount.objects.filter(imap_server="outlook.office365.com").exists(),
) )
def test_oauth_callback_view_unrecognized_scope(self) -> None:
"""
GIVEN:
- A valid OAuth state and a callback scope that is not Google's
WHEN:
- OAuth callback is called
THEN:
- 400 is returned instead of a server error and no account is created
"""
session = self.client.session
session["oauth_state"] = "test_state"
session.save()
for scope in ("", "openid", "https://example.com/other"):
with self.subTest(scope=scope):
response = self.client.get(
"/api/oauth/callback/",
{"code": "test_code", "scope": scope, "state": "test_state"},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertFalse(MailAccount.objects.exists())
@mock.patch("paperless_mail.mail.get_mailbox") @mock.patch("paperless_mail.mail.get_mailbox")
@mock.patch( @mock.patch(
"httpx_oauth.oauth2.BaseOAuth2.refresh_token", "httpx_oauth.oauth2.BaseOAuth2.refresh_token",
@@ -366,3 +388,39 @@ class TestMailOAuth(
f"Failed to refresh oauth token for account {mail_account}: test_error", f"Failed to refresh oauth token for account {mail_account}: test_error",
cm.output[0], cm.output[0],
) )
def test_refresh_account_oauth_token_non_oauth_account(self) -> None:
"""
GIVEN:
- A mail account which does not use OAuth
WHEN:
- A token refresh is requested
THEN:
- False is returned rather than an unbound variable error
"""
account = MailAccountFactory(
account_type=MailAccount.MailAccountType.IMAP,
refresh_token="token",
)
self.assertFalse(
PaperlessMailOAuth2Manager().refresh_account_oauth_token(account),
)
def test_refresh_account_oauth_token_without_refresh_token(self) -> None:
"""
GIVEN:
- An OAuth mail account with no refresh token stored
WHEN:
- A token refresh is requested
THEN:
- False is returned
"""
account = MailAccountFactory(
account_type=MailAccount.MailAccountType.GMAIL_OAUTH,
refresh_token=None,
)
self.assertFalse(
PaperlessMailOAuth2Manager().refresh_account_oauth_token(account),
)
+8
View File
@@ -319,6 +319,14 @@ class OauthCallbackView(GenericAPIView[Any]):
result = oauth_manager.get_outlook_access_token(code) result = oauth_manager.get_outlook_access_token(code)
else:
logger.error(
f"Invalid oauth callback request: unrecognized scope {scope!r}",
)
return HttpResponseBadRequest(
"Invalid request, see logs for more detail",
)
access_token = result["access_token"] access_token = result["access_token"]
refresh_token = result["refresh_token"] refresh_token = result["refresh_token"]
expires_in = result["expires_in"] expires_in = result["expires_in"]