mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
Frontend update + Misc fixes (#735)
- Updated frontend CSS to Tailwind v4 - Reverted socket IO origin restriction - Fixed search queries not persisting after auth redirect - Move advanced search options to left UI selector - Unlock IRC source to be used for audiobook content_type - Tweaked security settings env var syncing to be prioritised - Fix AA "all languages" query generation - Added language-free AA query as second fallback in case of no results - Testing moving SeleniumBase scratch files to /tmp via symlink - Added enhanced logging for activity dismissals and other events - Removed iFrame restrictions
This commit is contained in:
@@ -203,6 +203,12 @@ def test_request_policy_rules_source_options_are_dynamic(monkeypatch):
|
||||
"enabled": True,
|
||||
"supported_content_types": ["ebook", "audiobook"],
|
||||
},
|
||||
{
|
||||
"name": "irc",
|
||||
"display_name": "IRC",
|
||||
"enabled": True,
|
||||
"supported_content_types": ["ebook", "audiobook"],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
@@ -212,6 +218,7 @@ def test_request_policy_rules_source_options_are_dynamic(monkeypatch):
|
||||
assert source_options == [
|
||||
{"value": "direct_download", "label": "Direct Download"},
|
||||
{"value": "prowlarr", "label": "Prowlarr"},
|
||||
{"value": "irc", "label": "IRC"},
|
||||
]
|
||||
|
||||
content_type_column = columns[1]
|
||||
@@ -221,6 +228,8 @@ def test_request_policy_rules_source_options_are_dynamic(monkeypatch):
|
||||
assert {"value": "ebook", "label": "Ebook", "childOf": "direct_download"} in content_type_options
|
||||
assert {"value": "ebook", "label": "Ebook", "childOf": "prowlarr"} in content_type_options
|
||||
assert {"value": "audiobook", "label": "Audiobook", "childOf": "prowlarr"} in content_type_options
|
||||
assert {"value": "ebook", "label": "Ebook", "childOf": "irc"} in content_type_options
|
||||
assert {"value": "audiobook", "label": "Audiobook", "childOf": "irc"} in content_type_options
|
||||
assert {"value": "*", "label": "Any Type (*)", "childOf": "prowlarr"} not in content_type_options
|
||||
assert {"value": "*", "label": "Any Type (*)", "childOf": "direct_download"} not in content_type_options
|
||||
|
||||
|
||||
@@ -347,13 +347,22 @@ class TestActivityRoutes:
|
||||
_set_session(client, user_id=user["username"], db_user_id=None, is_admin=False)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
response = client.post(
|
||||
"/api/activity/dismiss",
|
||||
json={"item_type": "download", "item_key": "download:test-task"},
|
||||
)
|
||||
with patch("shelfmark.core.activity_routes.logger.warning") as mock_warning:
|
||||
response = client.post(
|
||||
"/api/activity/dismiss",
|
||||
json={"item_type": "download", "item_key": "download:test-task"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json["code"] == "user_identity_unavailable"
|
||||
mock_warning.assert_called_once()
|
||||
log_message = mock_warning.call_args.args[0]
|
||||
assert "Activity dismiss rejected" in log_message
|
||||
assert "status=403" in log_message
|
||||
assert "reason=User identity unavailable for activity workflow" in log_message
|
||||
assert "path=/api/activity/dismiss" in log_message
|
||||
assert f"user={user['username']}" in log_message
|
||||
assert "db_user_id=-" in log_message
|
||||
|
||||
def test_dismiss_returns_404_when_download_history_row_is_missing(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
@@ -535,15 +544,16 @@ class TestActivityRoutes:
|
||||
)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
response = client.post(
|
||||
"/api/activity/dismiss-many",
|
||||
json={
|
||||
"items": [
|
||||
{"item_type": "download", "item_key": f"download:{existing_task_id}"},
|
||||
{"item_type": "download", "item_key": "download:missing-bulk-task"},
|
||||
]
|
||||
},
|
||||
)
|
||||
with patch("shelfmark.core.activity_routes.logger.warning") as mock_warning:
|
||||
response = client.post(
|
||||
"/api/activity/dismiss-many",
|
||||
json={
|
||||
"items": [
|
||||
{"item_type": "download", "item_key": f"download:{existing_task_id}"},
|
||||
{"item_type": "download", "item_key": "download:missing-bulk-task"},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json["error"] == "One or more activity items were not found"
|
||||
@@ -552,6 +562,14 @@ class TestActivityRoutes:
|
||||
main_module,
|
||||
viewer_scope=f"user:{user['id']}",
|
||||
)
|
||||
mock_warning.assert_called_once()
|
||||
log_message = mock_warning.call_args.args[0]
|
||||
assert "Activity dismiss_many rejected" in log_message
|
||||
assert "status=404" in log_message
|
||||
assert "reason=One or more activity items were not found" in log_message
|
||||
assert "path=/api/activity/dismiss-many" in log_message
|
||||
assert "item_count=2" in log_message
|
||||
assert "missing_item_keys=download:missing-bulk-task" in log_message
|
||||
|
||||
def test_no_auth_dismiss_many_and_history_use_shared_identity(self, main_module):
|
||||
task_id = f"no-auth-{uuid.uuid4().hex[:10]}"
|
||||
@@ -664,6 +682,25 @@ class TestActivityRoutes:
|
||||
assert response.status_code == 403
|
||||
assert response.json["code"] == "user_identity_unavailable"
|
||||
|
||||
def test_clear_history_logs_identity_failure(self, main_module, client):
|
||||
admin = _create_user(main_module, prefix="admin", role="admin")
|
||||
_set_session(client, user_id=admin["username"], db_user_id=None, is_admin=True)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch("shelfmark.core.activity_routes.logger.warning") as mock_warning:
|
||||
response = client.delete("/api/activity/history")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json["code"] == "user_identity_unavailable"
|
||||
mock_warning.assert_called_once()
|
||||
log_message = mock_warning.call_args.args[0]
|
||||
assert "Activity history_clear rejected" in log_message
|
||||
assert "status=403" in log_message
|
||||
assert "reason=User identity unavailable for activity workflow" in log_message
|
||||
assert "path=/api/activity/history" in log_message
|
||||
assert f"user={admin['username']}" in log_message
|
||||
assert "is_admin=True" in log_message
|
||||
|
||||
def test_snapshot_backfills_undismissed_terminal_download_from_download_history(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Tests for auth mode and admin policy helpers used by OIDC integration."""
|
||||
|
||||
import sqlite3
|
||||
|
||||
from shelfmark.core.auth_modes import (
|
||||
determine_auth_mode,
|
||||
get_settings_tab_from_path,
|
||||
get_auth_check_admin_status,
|
||||
is_settings_or_onboarding_path,
|
||||
load_active_auth_mode,
|
||||
requires_admin_for_settings_access,
|
||||
should_restrict_settings_to_admin,
|
||||
)
|
||||
@@ -60,6 +63,18 @@ class TestDetermineAuthMode:
|
||||
}
|
||||
assert determine_auth_mode(config, cwa_db_path=None, has_local_admin=False) == "none"
|
||||
|
||||
def test_load_active_auth_mode_reads_env_backed_cwa_setting(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("AUTH_METHOD", "cwa")
|
||||
|
||||
cwa_db_path = tmp_path / "app.db"
|
||||
conn = sqlite3.connect(cwa_db_path)
|
||||
conn.execute("create table user (name text)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
assert load_active_auth_mode(cwa_db_path) == "cwa"
|
||||
|
||||
|
||||
class TestSettingsRestrictionPolicy:
|
||||
def test_settings_path_detection(self):
|
||||
|
||||
@@ -134,6 +134,30 @@ class TestOIDCLoginEndpoint:
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json()["error"] == "OIDC not configured"
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_login_stores_valid_return_to_in_session(self, mock_get_client, client):
|
||||
fake_client = Mock()
|
||||
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
|
||||
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
||||
|
||||
resp = client.get("/api/auth/oidc/login?return_to=%2F%3Fq%3DSanderson")
|
||||
|
||||
assert resp.status_code == 302
|
||||
with client.session_transaction() as sess:
|
||||
assert sess["oidc_return_to"] == "/?q=Sanderson"
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_login_ignores_unsafe_return_to(self, mock_get_client, client):
|
||||
fake_client = Mock()
|
||||
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
|
||||
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
||||
|
||||
resp = client.get("/api/auth/oidc/login?return_to=https://evil.example.com/phish")
|
||||
|
||||
assert resp.status_code == 302
|
||||
with client.session_transaction() as sess:
|
||||
assert "oidc_return_to" not in sess
|
||||
|
||||
|
||||
class TestOIDCCallbackEndpoint:
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
@@ -158,6 +182,62 @@ class TestOIDCCallbackEndpoint:
|
||||
assert sess["user_id"] == "john"
|
||||
assert sess["db_user_id"] is not None
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_redirects_to_original_url_with_query(self, mock_get_client, client):
|
||||
fake_client = Mock()
|
||||
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
|
||||
fake_client.authorize_access_token.return_value = {
|
||||
"userinfo": {
|
||||
"sub": "user-123",
|
||||
"email": "john@example.com",
|
||||
"name": "John Doe",
|
||||
"preferred_username": "john",
|
||||
"groups": ["users"],
|
||||
}
|
||||
}
|
||||
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
||||
|
||||
login_resp = client.get("/api/auth/oidc/login?return_to=%2F%3Fq%3DSanderson")
|
||||
assert login_resp.status_code == 302
|
||||
|
||||
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
||||
assert resp.status_code == 302
|
||||
|
||||
parsed = urlparse(resp.headers["Location"])
|
||||
assert parsed.path == "/"
|
||||
assert parse_qs(parsed.query) == {"q": ["Sanderson"]}
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_redirects_to_original_url_with_script_root(self, mock_get_client, client):
|
||||
fake_client = Mock()
|
||||
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
|
||||
fake_client.authorize_access_token.return_value = {
|
||||
"userinfo": {
|
||||
"sub": "user-123",
|
||||
"email": "john@example.com",
|
||||
"name": "John Doe",
|
||||
"preferred_username": "john",
|
||||
"groups": ["users"],
|
||||
}
|
||||
}
|
||||
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
||||
|
||||
login_resp = client.get(
|
||||
"/api/auth/oidc/login?return_to=%2Frequests%3Fq%3DSanderson",
|
||||
environ_overrides={"SCRIPT_NAME": "/shelfmark"},
|
||||
)
|
||||
assert login_resp.status_code == 302
|
||||
|
||||
resp = client.get(
|
||||
"/api/auth/oidc/callback?code=abc123&state=test-state",
|
||||
environ_overrides={"SCRIPT_NAME": "/shelfmark"},
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
|
||||
parsed = urlparse(resp.headers["Location"])
|
||||
assert parsed.path == "/shelfmark/requests"
|
||||
assert parse_qs(parsed.query) == {"q": ["Sanderson"]}
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_sets_admin_from_groups(self, mock_get_client, client):
|
||||
fake_client = Mock()
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from shelfmark.release_sources import Release
|
||||
from shelfmark.release_sources.irc import cache
|
||||
|
||||
|
||||
def test_cache_results_isolated_by_content_type(monkeypatch):
|
||||
state = {"entries": {}, "version": 1}
|
||||
|
||||
monkeypatch.setattr(cache, "_load_cache", lambda: state)
|
||||
monkeypatch.setattr(cache, "_save_cache", lambda _cache: None)
|
||||
|
||||
ebook_release = Release(source="irc", source_id="ebook", title="Shared Title", format="epub")
|
||||
audiobook_release = Release(source="irc", source_id="audio", title="Shared Title", format="zip")
|
||||
|
||||
cache.cache_results("hardcover", "123", "Shared Title", [ebook_release], content_type="ebook")
|
||||
cache.cache_results("hardcover", "123", "Shared Title", [audiobook_release], content_type="audiobook")
|
||||
|
||||
ebook_cached = cache.get_cached_results("hardcover", "123", content_type="ebook", ttl_seconds=60)
|
||||
audiobook_cached = cache.get_cached_results("hardcover", "123", content_type="audiobook", ttl_seconds=60)
|
||||
|
||||
assert [release.source_id for release in ebook_cached["releases"]] == ["ebook"]
|
||||
assert [release.source_id for release in audiobook_cached["releases"]] == ["audio"]
|
||||
@@ -0,0 +1,42 @@
|
||||
from shelfmark.release_sources.irc import parser
|
||||
|
||||
|
||||
def test_parse_results_file_uses_audiobook_format_settings(monkeypatch):
|
||||
values = {
|
||||
"SUPPORTED_FORMATS": ["epub"],
|
||||
"SUPPORTED_AUDIOBOOK_FORMATS": ["zip", "mp3"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(parser.config, "get", lambda key, default=None: values.get(key, default))
|
||||
|
||||
content = "\n".join(
|
||||
[
|
||||
"!AudioBot Author Name - Great Audio Book.zip ::INFO:: 1.2GB",
|
||||
"!AudioBot Author Name - Great Audio Book.mp3 ::INFO:: 1.1GB",
|
||||
"!AudioBot Author Name - Great Audio Book.epub ::INFO:: 5MB",
|
||||
]
|
||||
)
|
||||
|
||||
results = parser.parse_results_file(content, content_type="audiobook")
|
||||
|
||||
assert [result.format for result in results] == ["zip", "mp3"]
|
||||
|
||||
|
||||
def test_parse_results_file_uses_book_format_settings_for_ebooks(monkeypatch):
|
||||
values = {
|
||||
"SUPPORTED_FORMATS": ["epub"],
|
||||
"SUPPORTED_AUDIOBOOK_FORMATS": ["zip", "mp3"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(parser.config, "get", lambda key, default=None: values.get(key, default))
|
||||
|
||||
content = "\n".join(
|
||||
[
|
||||
"!BookBot Author Name - Great Book.zip ::INFO:: 50MB",
|
||||
"!BookBot Author Name - Great Book.epub ::INFO:: 5MB",
|
||||
]
|
||||
)
|
||||
|
||||
results = parser.parse_results_file(content, content_type="ebook")
|
||||
|
||||
assert [result.format for result in results] == ["epub"]
|
||||
@@ -0,0 +1,31 @@
|
||||
from shelfmark.release_sources.irc.parser import SearchResult
|
||||
from shelfmark.release_sources.irc.source import IRCReleaseSource
|
||||
|
||||
|
||||
def test_convert_to_releases_marks_audiobook_results_and_sorts_audio_before_archives():
|
||||
source = IRCReleaseSource()
|
||||
source._online_servers = set()
|
||||
|
||||
results = [
|
||||
SearchResult(
|
||||
server="AudioBot",
|
||||
author="Author Name",
|
||||
title="Archive Release",
|
||||
format="zip",
|
||||
size="1.2GB",
|
||||
full_line="!AudioBot Author Name - Archive Release.zip ::INFO:: 1.2GB",
|
||||
),
|
||||
SearchResult(
|
||||
server="AudioBot",
|
||||
author="Author Name",
|
||||
title="Direct Release",
|
||||
format="m4b",
|
||||
size="900MB",
|
||||
full_line="!AudioBot Author Name - Direct Release.m4b ::INFO:: 900MB",
|
||||
),
|
||||
]
|
||||
|
||||
releases = source._convert_to_releases(results, content_type="audiobook")
|
||||
|
||||
assert [release.format for release in releases] == ["m4b", "zip"]
|
||||
assert all(release.content_type == "audiobook" for release in releases)
|
||||
Reference in New Issue
Block a user