Compare commits

..

4 Commits

Author SHA1 Message Date
Christian Krakau-Louis ffc456ef75 Merge pull request #821 from christianlouis/copilot/sub-pr-816
fix(integrations): harden saved-integration test endpoint & resolve merge conflicts
2026-03-23 17:25:53 +01:00
copilot-swe-agent[bot] 1a195a96bd fix: merge main, address code review feedback for security fix PR #816
- Merge origin/main into branch (resolve conflict in integrations_dashboard.html)
- Add defensive JSON parsing with try/except for integration.config
- Wrap tester() call in try/except to prevent 500 errors from bad config
- Add i18n key integrations.connection_test_failed_fallback in en.json
- Reference i18n key in template JS fallback message
- Update SECURITY_AUDIT.md: add fix date (2026-03-23), update doc date
- Remove accidental revert.sh file
- Fix missing MagicMock/patch imports in test file
- Add tests for invalid JSON config and tester exception error paths

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/daebb70e-059a-4601-8864-88eef49f99cf
2026-03-23 16:21:09 +00:00
copilot-swe-agent[bot] 41d6f682c0 Initial plan 2026-03-23 16:12:43 +00:00
google-labs-jules[bot] 53961eb2c6 🛡️ Sentinel: [HIGH] Fix Insecure API Endpoint Exposing Integration Credentials
Removed the `/api/integrations/{integration_id}/credentials` endpoint which exposed plaintext credentials.
Created a new `/api/integrations/{integration_id}/test` endpoint to securely perform connection tests server-side.
Updated frontend to use the new secure test endpoint.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-23 15:13:35 +00:00
74 changed files with 578 additions and 1674 deletions
+31 -3
View File
@@ -3,10 +3,21 @@ WORKDIR=/workdir
DATABASE_URL=sqlite:///./app/database.db
REDIS_URL=redis://redis:6379/0
EXTERNAL_HOSTNAME=docuelevate.example.com
# PUBLIC_BASE_URL=https://docuelevate.example.com # Full URL with scheme; required when X-Forwarded-Proto is not forwarded by your proxy
GOTENBERG_URL=http://gotenberg:3000
ALLOW_FILE_DELETE=true # Allow deletion of file records
COMPLIANCE_ENABLED=true # Enable compliance templates dashboard (GDPR, HIPAA, SOC 2)
# **Database Connection Pool** (PostgreSQL / MySQL only; ignored for SQLite)
# DB_POOL_SIZE=10 # Persistent connections per worker (default: 10)
# DB_MAX_OVERFLOW=20 # Extra connections under burst (default: 20)
# DB_POOL_TIMEOUT=30 # Seconds to wait for a pool connection (default: 30)
# DB_POOL_RECYCLE=1800 # Recycle connections after N seconds (default: 1800)
# **Per-User Upload Rate Limiting** (health-aware, Redis-backed)
# UPLOAD_RATE_LIMIT_PER_USER=20 # Max uploads per user per window (default: 20)
# UPLOAD_RATE_LIMIT_WINDOW=60 # Sliding window in seconds (default: 60)
# **System Reset / Factory Reset**
# FACTORY_RESET_ON_STARTUP=false # Wipe all user data on every startup (demo/testing only)
# ENABLE_FACTORY_RESET=false # Show the System Reset page in admin UI
@@ -625,9 +636,26 @@ EMBEDDING_MAX_TOKENS=8000
# Profiling is only active when SENTRY_TRACES_SAMPLE_RATE > 0. Default: 0.0 (disabled).
# SENTRY_PROFILES_SAMPLE_RATE=0.0
#
# Attach PII (IP addresses, user agents) to Sentry events.
# Disable (default) to stay GDPR/CCPA compliant.
# SENTRY_SEND_DEFAULT_PII=false
# Attach PII (IP addresses, user agents) to Sentry events.
# Disable (default) to stay GDPR/CCPA compliant.
# SENTRY_SEND_DEFAULT_PII=false
#
# --- Browser (JavaScript) SDK ---
# The same DSN is reused for the Sentry Browser SDK which is injected into
# every rendered page. The DSN is a *public* key and is intentionally
# embedded in client-side code.
#
# Fraction of browser navigations captured for client-side performance tracing.
# 0.0 (default) disables browser tracing; 1.0 captures every navigation.
# SENTRY_JS_TRACES_SAMPLE_RATE=0.0
#
# Fraction of browser sessions recorded by Sentry Session Replay.
# 0.0 (default) disables session recording; 1.0 records every session.
# SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE=0.0
#
# Fraction of error sessions recorded by Sentry Session Replay.
# Defaults to 0.1 (10 %) so errors are captured with replay context.
# SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE=0.1
# **Mobile App Push Notifications**
# Push notifications are delivered via Expo's push notification service
+1 -1
View File
@@ -100,7 +100,7 @@ jobs:
python-version: "3.11"
cache: 'pip'
- run: pip install pip-audit>=2.7.0
- run: pip-audit -r requirements.txt --desc on --ignore-vuln CVE-2026-4539
- run: pip-audit -r requirements.txt --desc on
run-tests:
name: Execute All Tests (Quick + Integration)
+3
View File
@@ -200,3 +200,6 @@ cython_debug/
# Build metadata files - generated at build time
GIT_SHA
RUNTIME_INFO
# Frontend build tooling
frontend/node_modules/
+3
View File
@@ -0,0 +1,3 @@
[submodule "vendor/embed-pdf-viewer"]
path = vendor/embed-pdf-viewer
url = https://github.com/embedpdf/embed-pdf-viewer.git
-26
View File
@@ -1,12 +1,3 @@
## 2024-05-24 - SSRF in WebDAV connection test
**Vulnerability:** The `_test_webdav_connection` function had a custom SSRF check that failed to resolve DNS names, allowing attackers to bypass the check by providing a domain that resolves to an internal IP (e.g., `127.0.0.1`).
**Learning:** DNS resolution is required for robust SSRF protection when validating URLs provided by users.
**Prevention:** Use a centralized `is_private_ip` function (now in `app/utils/network.py`) that resolves the hostname to its IPs and checks if any are private.
## 2026-03-22 - B310: urllib.request.urlopen replaced with httpx
**Vulnerability:** The `_test_webdav_connection` function used `urllib.request.urlopen`, which natively supports dangerous schemes like `file://` or `ftp://` and follows redirects by default, potentially allowing SSRF bypasses or Local File Inclusion.
**Learning:** `urllib.request` should be avoided for user-supplied URLs. Even when URL schemes are manually validated, `urllib`'s default redirect following behavior can bypass SSRF protections (e.g. redirecting to `127.0.0.1`).
**Prevention:** Use a modern, safer HTTP client like `httpx` with `follow_redirects=False` when testing user-provided URLs.
## 2026-03-20 - Safe Path Traversal Prevention in Low-Level Utilities
**Vulnerability:** The generic file utility `hash_file` in `app/utils/file_operations.py` accepted any file path and was vulnerable to reading arbitrary files via path traversal (e.g., `../../../etc/passwd`) or absolute paths if an attacker could control the `filepath` argument.
**Learning:** Naively checking for `".." in path` breaks legitimate relative paths used internally by the application. Blocking absolute paths entirely also breaks functionality. Input validation should occur at the API boundary, but for defense-in-depth, low-level utilities must enforce expected boundaries (e.g., the application's `workdir`).
@@ -15,20 +6,3 @@
**Vulnerability:** The `is_private_ip` function in `app/utils/network.py` failed open (returned `False`) when a hostname could not be resolved (`socket.gaierror`).
**Learning:** This fail-open pattern was originally added to allow external domains in tests, but in production, it created a severe SSRF risk. An attacker could bypass SSRF protections by providing a URL that fails to resolve during the security check but resolves later (DNS rebinding), or by exploiting internal routing behaviors via unresolvable addresses.
**Prevention:** Always fail securely in network authorization functions. If a domain cannot be resolved to verify its safety, the request must be blocked (`return True` / default-deny). Tests should mock DNS resolution correctly instead of compromising production security logic.
## 2026-03-26 - SSRF in Integration Connection Tests
**Vulnerability:** The `_test_imap_connection` and `_test_s3_connection` functions in `app/api/integrations.py` did not validate user-provided `host` and `endpoint_url` variables against `is_private_ip()`. This allowed an attacker to test the presence of internal IMAP servers or direct S3 SDK API calls to internal infrastructure via SSRF.
**Learning:** Any time a new generic connection or integration test is added, SSRF validation may be forgotten if the core network utility (`is_private_ip`) is not systematically applied to all outbound network operations, regardless of the protocol (e.g., IMAP, S3).
**Prevention:** Establish a pattern where any user-configurable host or endpoint URL is immediately passed through the centralized `is_private_ip` validation function before any network call or third-party client initialization.
## 2024-05-27 - SSRF Bypass via HTTP Redirects
**Vulnerability:** In `app/api/url_upload.py`, the `validate_url_safety` function was correctly verifying the initially requested URL to prevent fetching internal IPs or cloud metadata endpoints. However, the subsequent `httpx.AsyncClient` was configured with `follow_redirects=True` without validating the destination of those redirects. An attacker could bypass SSRF protections by providing a URL to an attacker-controlled server that responds with a 301/302 redirect pointing to an internal target (e.g., `http://127.0.0.1` or `http://169.254.169.254`).
**Learning:** Checking the URL before sending the request is insufficient if the HTTP client automatically follows redirects. The target of every single redirect must be subject to the same strict validation as the initial request.
**Prevention:** Avoid `follow_redirects=True` for user-provided URLs when possible. If redirects must be followed, attach an event hook (e.g., `event_hooks={"response": [hook_function]}`) to the `httpx` client to intercept the response, calculate the redirect destination from the `Location` header, and run the URL safety validation logic before the redirect is actually followed.
## 2026-03-27 - SSRF Bypass via HTTP Redirects in httpx
**Vulnerability:** The `/process-url` endpoint used `httpx.AsyncClient(follow_redirects=True)` after validating the initial user-provided URL against SSRF protections. However, it did not validate the target URLs of any subsequent HTTP redirects, allowing an attacker to provide a safe URL that redirects to an internal/private IP, bypassing the security check.
**Learning:** Initial URL validation is insufficient when the HTTP client is configured to follow redirects automatically. The client must be explicitly configured to validate every redirect target.
**Prevention:** When using `httpx.AsyncClient(follow_redirects=True)` for user-provided URLs, always implement a redirect validator hook function (e.g., using `event_hooks={'response': [validate_redirect]}`) that resolves the `Location` header and passes it through the same SSRF validation logic before the redirect is followed.
## 2026-03-27 - SyntaxError: keyword argument repeated in httpx event hooks
**Vulnerability:** The `/process-url` endpoint in `app/api/url_upload.py` initialized `httpx.AsyncClient` with the `event_hooks` keyword argument twice. This caused a Python SyntaxError, effectively crashing the API endpoint and preventing any execution.
**Learning:** Python does not allow duplicate keyword arguments. In scenarios where multiple hooks (like local and module-level SSRF interceptors) must be provided to a client, they must be merged into a single list value.
**Prevention:** Combine multiple callables for the same event key into a single list, e.g., `event_hooks={"response": [hook1, hook2]}`.
+1 -1
View File
@@ -1 +1 @@
2026-04-07T09:34:53Z
2026-03-23T14:11:22Z
-358
View File
@@ -12,342 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
## v0.172.9 (2026-04-07)
### Bug Fixes
- **api**: Resolve merge conflicts, add type safety for endpoint_url in S3 connection test
([`57db4c7`](https://github.com/christianlouis/DocuElevate/commit/57db4c7c82f4a8df2e7e5e5505e1d5c01768fc16))
### Chores
- **ci**: Ignore CVE-2026-4539 in pip-audit until pygments releases a fix
([`6927e76`](https://github.com/christianlouis/DocuElevate/commit/6927e7643f9cbe1664f4a1a093511df5b079ed0a))
### Code Style
- Apply ruff auto-fix
([`8295279`](https://github.com/christianlouis/DocuElevate/commit/8295279ec93570da4eb0445ede8084d1eb2aba99))
- Sort imports in test_url_upload.py
([`bdfa3ba`](https://github.com/christianlouis/DocuElevate/commit/bdfa3ba1e0a5702414e3b449fbde6a6d3149557a))
### Documentation
- **changelog**: Update changelog [skip ci]
([`c6e0b80`](https://github.com/christianlouis/DocuElevate/commit/c6e0b80becab81a75aea4ee78f5aaf8b6ac54854))
- **changelog**: Update changelog [skip ci]
([`9b9882c`](https://github.com/christianlouis/DocuElevate/commit/9b9882c4d62691d0ddd20444e3b77bfe6eecc8c3))
- **changelog**: Update changelog [skip ci]
([`69053bf`](https://github.com/christianlouis/DocuElevate/commit/69053bfb08d3e2f12a86878044667ac500888837))
- **changelog**: Update changelog [skip ci]
([`76f202f`](https://github.com/christianlouis/DocuElevate/commit/76f202f7f1b94e39a4e79cd984770310599405bf))
### Testing
- Add coverage for url_upload redirect SSRF bypass prevention hook
([`152ee15`](https://github.com/christianlouis/DocuElevate/commit/152ee15b06ebf7beb6216423b4c8d93ec2243165))
- Add tests for SSRF validation in integrations
([`470f08d`](https://github.com/christianlouis/DocuElevate/commit/470f08d89322f2904b78a8b0f820973611486c26))
## Unreleased
### Chores
- **ci**: Ignore CVE-2026-4539 in pip-audit until pygments releases a fix
([`6927e76`](https://github.com/christianlouis/DocuElevate/commit/6927e7643f9cbe1664f4a1a093511df5b079ed0a))
### Documentation
- **changelog**: Update changelog [skip ci]
([`9b9882c`](https://github.com/christianlouis/DocuElevate/commit/9b9882c4d62691d0ddd20444e3b77bfe6eecc8c3))
- **changelog**: Update changelog [skip ci]
([`69053bf`](https://github.com/christianlouis/DocuElevate/commit/69053bfb08d3e2f12a86878044667ac500888837))
- **changelog**: Update changelog [skip ci]
([`76f202f`](https://github.com/christianlouis/DocuElevate/commit/76f202f7f1b94e39a4e79cd984770310599405bf))
### Testing
- Add tests for SSRF validation in integrations
([`470f08d`](https://github.com/christianlouis/DocuElevate/commit/470f08d89322f2904b78a8b0f820973611486c26))
## Unreleased
### Chores
- **ci**: Ignore CVE-2026-4539 in pip-audit until pygments releases a fix
([`6927e76`](https://github.com/christianlouis/DocuElevate/commit/6927e7643f9cbe1664f4a1a093511df5b079ed0a))
### Documentation
- **changelog**: Update changelog [skip ci]
([`69053bf`](https://github.com/christianlouis/DocuElevate/commit/69053bfb08d3e2f12a86878044667ac500888837))
- **changelog**: Update changelog [skip ci]
([`76f202f`](https://github.com/christianlouis/DocuElevate/commit/76f202f7f1b94e39a4e79cd984770310599405bf))
### Testing
- Add tests for SSRF validation in integrations
([`470f08d`](https://github.com/christianlouis/DocuElevate/commit/470f08d89322f2904b78a8b0f820973611486c26))
## Unreleased
### Chores
- **ci**: Ignore CVE-2026-4539 in pip-audit until pygments releases a fix
([`6927e76`](https://github.com/christianlouis/DocuElevate/commit/6927e7643f9cbe1664f4a1a093511df5b079ed0a))
### Documentation
- **changelog**: Update changelog [skip ci]
([`76f202f`](https://github.com/christianlouis/DocuElevate/commit/76f202f7f1b94e39a4e79cd984770310599405bf))
## Unreleased
### Chores
- **ci**: Ignore CVE-2026-4539 in pip-audit until pygments releases a fix
([`6927e76`](https://github.com/christianlouis/DocuElevate/commit/6927e7643f9cbe1664f4a1a093511df5b079ed0a))
## v0.172.8 (2026-03-25)
### Bug Fixes
- **api**: Track env_file_written accurately in save_google_drive_settings
([`4136033`](https://github.com/christianlouis/DocuElevate/commit/4136033bf0e580cbabe811084ab47ea0d6af8f9a))
- **tests**: Add admin override fixture to TestSaveDropboxSettings
([`cafc0e4`](https://github.com/christianlouis/DocuElevate/commit/cafc0e45230ffea096664c1947ac20753b63f8e9))
- **tests**: Restore correct route URLs and fix auth/exception handling broken by d221753
([`48331f6`](https://github.com/christianlouis/DocuElevate/commit/48331f6e91e6c0dae31ab3be31f9da1eccd0a549))
### Documentation
- **changelog**: Update changelog [skip ci]
([`c3124b0`](https://github.com/christianlouis/DocuElevate/commit/c3124b08bd48faa32e76d21d744c9902214048a7))
## Unreleased
## v0.172.7 (2026-03-24)
### Bug Fixes
- **api**: Remove duplicate Depends from AdminUser parameters in dropbox, onedrive, google_drive
([`7f20c90`](https://github.com/christianlouis/DocuElevate/commit/7f20c903ef23e138518e715c7c7a0297d3e46ff8))
- **dockerfile**: Add frontend-builder stage to compile Tailwind CSS
([`3fd8b32`](https://github.com/christianlouis/DocuElevate/commit/3fd8b32724e3d390ff723e5b090a5603c3b1fc93))
- **main**: Replace silent except-pass with exception logging to fix S110
([`8fcc223`](https://github.com/christianlouis/DocuElevate/commit/8fcc223ef19cf609d8413fb6091eabfa0b34d4a6))
## v0.172.6 (2026-03-24)
### Bug Fixes
- Resolve multiple test failures in imap_tasks, main lifespan, and API settings endpoints
([`f041f28`](https://github.com/christianlouis/DocuElevate/commit/f041f28d9f64011df52506ead4bbc87d0797c20e))
- Restore all code deleted/truncated by d2217531 Jules SSRF commit
([`c7d3ec5`](https://github.com/christianlouis/DocuElevate/commit/c7d3ec57c3aca4faeaa0ad2fdbde3a1f770b86a5))
- **migrations**: Restore accidentally deleted migration files 038-042
([`11a49eb`](https://github.com/christianlouis/DocuElevate/commit/11a49eb7fd2218062922a9b8bf01b9a91572bea7))
- **tasks**: Add -- end-of-options separator to ocrmypdf command in convert_to_pdfa
([`7dec570`](https://github.com/christianlouis/DocuElevate/commit/7dec570ce6ae40b934ad075bc06f4cf1dfd9ff2e))
### Code Style
- Apply ruff auto-fix
([`627a857`](https://github.com/christianlouis/DocuElevate/commit/627a8579def3a6a9d4f78da6469cfde889154402))
## v0.172.5 (2026-03-23)
### Bug Fixes
- **main**: Suppress S110 ruff warnings with noqa comments for intentional try-except-pass
([`0b8f967`](https://github.com/christianlouis/DocuElevate/commit/0b8f967eb5e304155752b4492584d4a7509a454c))
- **settings**: Move os.path.exists inside try block in update_env_file so exceptions are non-fatal
([`c9bb2b6`](https://github.com/christianlouis/DocuElevate/commit/c9bb2b6807b371d04edff12d16f838f411b60514))
### Testing
- **google_drive**: Fix exception handling test to expect non-fatal 200 like OneDrive equivalent
([`2f5e2a0`](https://github.com/christianlouis/DocuElevate/commit/2f5e2a0fcdd9f9532fc55c6d7ce1675b8be3d3e8))
- **main,imap**: Fix failing IMAP tests and add coverage for shutdown exception paths
([`c03ce8c`](https://github.com/christianlouis/DocuElevate/commit/c03ce8cdb2e7849361ea50db888b7e3080eaafcd))
## v0.172.4 (2026-03-23)
### Bug Fixes
- Resolve failing tests in main
([`3be93be`](https://github.com/christianlouis/DocuElevate/commit/3be93be35a1564cb6009c5b7d2229820c2b8fafd))
- **api/dropbox**: _require_admin bypasses auth when AUTH_ENABLED=False,
([`3be93be`](https://github.com/christianlouis/DocuElevate/commit/3be93be35a1564cb6009c5b7d2229820c2b8fafd))
### Chores
- Simplify and fix naming for save settings endpoints
([`341839f`](https://github.com/christianlouis/DocuElevate/commit/341839fe5edafa3451f89e2bb57092882d8fd6f0))
- Simplify and fix naming for save settings endpoints
([`57795ee`](https://github.com/christianlouis/DocuElevate/commit/57795ee4871bb0bb0727037a889542bf46a8bb9e))
### Code Style
- Apply ruff auto-fix
([`2014a93`](https://github.com/christianlouis/DocuElevate/commit/2014a93c1ba4f41b8cfb589584be8d39baeaffe1))
### Documentation
- **changelog**: Update changelog [skip ci]
([`bcdbf9d`](https://github.com/christianlouis/DocuElevate/commit/bcdbf9d17885ab3f8750d8426c0ee9f181ced736))
## Unreleased
### Chores
- Simplify and fix naming for save settings endpoints
([`341839f`](https://github.com/christianlouis/DocuElevate/commit/341839fe5edafa3451f89e2bb57092882d8fd6f0))
- Simplify and fix naming for save settings endpoints
([`57795ee`](https://github.com/christianlouis/DocuElevate/commit/57795ee4871bb0bb0727037a889542bf46a8bb9e))
## v0.172.3 (2026-03-23)
### Bug Fixes
- Improve join_url - use walrus op, remove posixpath.normpath
([`15dd1a8`](https://github.com/christianlouis/DocuElevate/commit/15dd1a847133aa02aedf65e7fc75d857151cc26e))
### Code Style
- Apply ruff auto-fix
([`b50a534`](https://github.com/christianlouis/DocuElevate/commit/b50a534454f0432e2ada8140e0090535b7c97051))
- Apply ruff auto-fix
([`326adb1`](https://github.com/christianlouis/DocuElevate/commit/326adb185853e17ac02d30b1bcce33b3a1cf4c5c))
### Documentation
- **changelog**: Update changelog [skip ci]
([`248619d`](https://github.com/christianlouis/DocuElevate/commit/248619d91e91aa9c5660267813367e4cd6f5040f))
- **changelog**: Update changelog [skip ci]
([`26963a8`](https://github.com/christianlouis/DocuElevate/commit/26963a84643c8c5caeb8536ed4dc55302a517adf))
- **changelog**: Update changelog [skip ci]
([`78bd5b5`](https://github.com/christianlouis/DocuElevate/commit/78bd5b5904d41d77d8df2a0e3978f630be080f0f))
- **changelog**: Update changelog [skip ci]
([`cc5e879`](https://github.com/christianlouis/DocuElevate/commit/cc5e879ea98507ec5656cce7162a69d385ee00f2))
- **changelog**: Update changelog [skip ci]
([`0497fbb`](https://github.com/christianlouis/DocuElevate/commit/0497fbbbad71fd728e528498508bbfc7802dab70))
- **changelog**: Update changelog [skip ci]
([`45d3ac8`](https://github.com/christianlouis/DocuElevate/commit/45d3ac8cf07d39d49930dd6866f76e6015067b08))
### Testing
- Add assertions for task enqueuing parameters
([`eeae47d`](https://github.com/christianlouis/DocuElevate/commit/eeae47ddec01339421e503ba484157e798750b8a))
## Unreleased
### Documentation
- **changelog**: Update changelog [skip ci]
([`26963a8`](https://github.com/christianlouis/DocuElevate/commit/26963a84643c8c5caeb8536ed4dc55302a517adf))
- **changelog**: Update changelog [skip ci]
([`78bd5b5`](https://github.com/christianlouis/DocuElevate/commit/78bd5b5904d41d77d8df2a0e3978f630be080f0f))
- **changelog**: Update changelog [skip ci]
([`cc5e879`](https://github.com/christianlouis/DocuElevate/commit/cc5e879ea98507ec5656cce7162a69d385ee00f2))
- **changelog**: Update changelog [skip ci]
([`0497fbb`](https://github.com/christianlouis/DocuElevate/commit/0497fbbbad71fd728e528498508bbfc7802dab70))
- **changelog**: Update changelog [skip ci]
([`45d3ac8`](https://github.com/christianlouis/DocuElevate/commit/45d3ac8cf07d39d49930dd6866f76e6015067b08))
### Testing
- Add assertions for task enqueuing parameters
([`eeae47d`](https://github.com/christianlouis/DocuElevate/commit/eeae47ddec01339421e503ba484157e798750b8a))
## Unreleased
### Documentation
- **changelog**: Update changelog [skip ci]
([`78bd5b5`](https://github.com/christianlouis/DocuElevate/commit/78bd5b5904d41d77d8df2a0e3978f630be080f0f))
- **changelog**: Update changelog [skip ci]
([`cc5e879`](https://github.com/christianlouis/DocuElevate/commit/cc5e879ea98507ec5656cce7162a69d385ee00f2))
- **changelog**: Update changelog [skip ci]
([`0497fbb`](https://github.com/christianlouis/DocuElevate/commit/0497fbbbad71fd728e528498508bbfc7802dab70))
- **changelog**: Update changelog [skip ci]
([`45d3ac8`](https://github.com/christianlouis/DocuElevate/commit/45d3ac8cf07d39d49930dd6866f76e6015067b08))
### Testing
- Add assertions for task enqueuing parameters
([`eeae47d`](https://github.com/christianlouis/DocuElevate/commit/eeae47ddec01339421e503ba484157e798750b8a))
## Unreleased
### Documentation
- **changelog**: Update changelog [skip ci]
([`cc5e879`](https://github.com/christianlouis/DocuElevate/commit/cc5e879ea98507ec5656cce7162a69d385ee00f2))
- **changelog**: Update changelog [skip ci]
([`0497fbb`](https://github.com/christianlouis/DocuElevate/commit/0497fbbbad71fd728e528498508bbfc7802dab70))
- **changelog**: Update changelog [skip ci]
([`45d3ac8`](https://github.com/christianlouis/DocuElevate/commit/45d3ac8cf07d39d49930dd6866f76e6015067b08))
### Testing
- Add assertions for task enqueuing parameters
([`eeae47d`](https://github.com/christianlouis/DocuElevate/commit/eeae47ddec01339421e503ba484157e798750b8a))
## Unreleased
### Documentation
- **changelog**: Update changelog [skip ci]
@@ -400,28 +64,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
## v0.172.2 (2026-03-23)
### Bug Fixes
- Adapt TemplateResponse calls to Starlette 1.0 new-style API
([`c4e10be`](https://github.com/christianlouis/DocuElevate/commit/c4e10bee5e096e71a5bc4fac4928f69e5c04f2fb))
- Update test assertions and lint fixes for Starlette 1.0 TemplateResponse API
([`93629ff`](https://github.com/christianlouis/DocuElevate/commit/93629ff44083d43f79fdd49431457023e53d13e4))
- **build**: Remove --omit=dev from npm ci in Dockerfile frontend-builder stage
([`b4e0067`](https://github.com/christianlouis/DocuElevate/commit/b4e0067a27e2fb161349bd38c6d3b3f3bcb86972))
### Documentation
- **changelog**: Update changelog [skip ci]
([`0841713`](https://github.com/christianlouis/DocuElevate/commit/084171395d1076c716aa500a516118db49468ff5))
## Unreleased
## v0.172.1 (2026-03-22)
### Bug Fixes
+8 -10
View File
@@ -27,21 +27,20 @@ RUN pip install --no-cache-dir -r requirements.txt \
&& find /opt/venv -type f -name "*.pyc" -delete \
&& find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
# ── Stage 2: Frontend asset builder ─────────────────────────────────────────
# Compiles Tailwind CSS (a devDependency) into the minified styles.css.
# npm ci installs ALL deps (including devDependencies) so the tailwindcss CLI
# is available; using --omit=dev would cause 'tailwindcss: not found'.
FROM node:20-slim AS frontend-builder
# ── Stage 2: Frontend asset builder (Tailwind CSS) ──────────────────────────
FROM node:20-alpine AS frontend-builder
WORKDIR /frontend
# Install dependencies first (layer-cached unless package.json/lockfile changes)
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
# Copy source files and compile Tailwind CSS
COPY frontend/ ./
RUN npm run build
# ── Stage 4: Documentation builder ──────────────────────────────────────────
# ── Stage 3: Documentation builder ──────────────────────────────────────────
FROM python:3.14.3-slim AS docs-builder
WORKDIR /docs
@@ -57,7 +56,7 @@ COPY mkdocs.yml /docs/mkdocs.yml
# Build the static documentation site
RUN mkdocs build --config-file /docs/mkdocs.yml --site-dir /docs/docs_build
# ── Stage 5: Runtime image ───────────────────────────────────────────────────
# ── Stage 4: Runtime image ───────────────────────────────────────────────────
FROM python:3.14.3-slim
WORKDIR /app
@@ -82,6 +81,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Copy application code
COPY ./app /app/app
COPY ./frontend /app/frontend
# Overlay compiled Tailwind CSS from the frontend build stage
COPY --from=frontend-builder /frontend/static/styles.css /app/frontend/static/styles.css
COPY ./migrations /app/migrations
COPY ./alembic.ini /app/alembic.ini
COPY ./LICENSE /app/LICENSE
@@ -95,9 +96,6 @@ COPY ./RUNTIME_INFO /app/RUNTIME_INFO
# Copy the pre-built MkDocs documentation site (served at /help)
COPY --from=docs-builder /docs/docs_build /app/docs_build
# Copy the compiled Tailwind CSS (built in the frontend-builder stage)
COPY --from=frontend-builder /frontend/static/styles.css /app/frontend/static/styles.css
# Create necessary runtime directories in a single layer
RUN mkdir -p /app/runtime_info /workdir
+1 -1
View File
@@ -1 +1 @@
3bd8a52
34457f9
+6 -6
View File
@@ -1,10 +1,10 @@
DocuElevate Build Information
==============================
Version: 0.172.9
Build Date: 2026-04-07T09:34:53Z
Git Commit: 3bd8a52ea201b33d6071c9b3a7fdace582e65fd5
Git Short SHA: 3bd8a52
Version: 0.172.2
Build Date: 2026-03-23T14:11:22Z
Git Commit: 34457f977509ce145b7411e83982a96b0fd0e33e
Git Short SHA: 34457f9
Git Branch: main
Commit Date: 2026-04-07T11:34:28+02:00
Build Timestamp: 2026-04-07T09:34:53Z
Commit Date: 2026-03-23T15:10:59+01:00
Build Timestamp: 2026-03-23T14:11:22Z
==============================
+10 -1
View File
@@ -1,6 +1,6 @@
# Security Audit Report
**Date:** 2026-02-12
**Date:** 2026-03-23
**Status:** Bandit Security Scan Completed - All Critical/High/Medium Issues Resolved
## Executive Summary
@@ -9,6 +9,15 @@ This document tracks security vulnerabilities found in DocuElevate and their rem
## Recent Security Fixes
### Insecure API Endpoint Exposing Integration Credentials ✅ FIXED (2026-03-23)
**Severity:** HIGH
**Issue:** The endpoint `GET /api/integrations/{integration_id}/credentials` exposed integration credentials (e.g. passwords, API keys) in plaintext over the API. Although requiring login, this allowed anyone with an active user session to extract the raw credentials. The frontend used this endpoint for testing integration connections.
**Remediation:**
- Removed the `/credentials` endpoint entirely.
- Added a new `POST /api/integrations/{integration_id}/test` endpoint that securely runs connection tests server-side without returning the decrypted credentials to the client.
### CVE-2023-36464: PyPDF2/pypdf Infinite Loop Vulnerability ✅ FIXED (2026-02-12)
**Severity:** Moderate (CVSS: 5.5)
+1 -1
View File
@@ -1 +1 @@
0.172.9
0.172.2
+2 -13
View File
@@ -25,17 +25,6 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _require_admin(request: Request) -> dict:
"""Ensure the caller is an admin. Raises 403 otherwise."""
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
AdminUser = Annotated[dict, Depends(_require_admin)]
def _build_dropbox_redirect_uri(request: Request) -> str:
"""Build the Dropbox OAuth callback redirect URI.
@@ -396,14 +385,14 @@ async def list_dropbox_folders(
@router.post("/dropbox/save-settings")
@require_login
async def save_dropbox_settings(
request: Request,
refresh_token: Annotated[str, Form(...)],
_admin: AdminUser,
db: Session = Depends(get_db),
app_key: Annotated[Optional[str], Form()] = None,
app_secret: Annotated[Optional[str], Form()] = None,
folder_path: Annotated[Optional[str], Form()] = None,
db: Session = Depends(get_db),
):
"""
Save Dropbox settings to database (primary) and .env file (best-effort).
+12 -25
View File
@@ -23,17 +23,6 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _require_admin(request: Request) -> dict:
"""Ensure the caller is an admin. Raises 403 otherwise."""
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
AdminUser = Annotated[dict, Depends(_require_admin)]
@router.post("/google-drive/exchange-token")
@require_login
async def exchange_google_drive_token(
@@ -373,15 +362,15 @@ def format_time_remaining(time_delta):
@router.post("/google-drive/save-settings")
@require_login
async def save_google_drive_settings(
request: Request,
refresh_token: Annotated[str, Form(...)],
_admin: AdminUser,
db: Session = Depends(get_db),
client_id: Annotated[Optional[str], Form()] = None,
client_secret: Annotated[Optional[str], Form()] = None,
folder_id: Annotated[Optional[str], Form()] = None,
use_oauth: Annotated[str, Form()] = "true",
db: Session = Depends(get_db),
):
"""
Save Google Drive settings to the .env file (best-effort) and persist to database.
@@ -414,10 +403,9 @@ async def save_google_drive_settings(
if folder_id:
drive_settings["GOOGLE_DRIVE_FOLDER_ID"] = folder_id
# Best-effort .env file write — failures here are non-fatal
env_file_written = False
try:
if os.path.exists(env_path):
# Try to update the .env file, but don't fail if it doesn't exist (for Docker containers)
if os.path.exists(env_path):
try:
logger.info(f"Updating Google Drive settings in {env_path}")
# Read the current .env file
@@ -450,13 +438,12 @@ async def save_google_drive_settings(
f.write("\n".join(new_env_lines) + "\n")
logger.info("Successfully updated Google Drive settings in .env file")
env_file_written = True
else:
logger.warning(
f".env file not found at {env_path}, skipping file update but continuing with in-memory update"
)
except Exception as env_err:
logger.warning(f"Failed to write .env file (non-fatal): {env_err}")
except Exception as e:
logger.warning(f"Failed to update .env file: {str(e)}, but will continue with in-memory update")
else:
logger.warning(
f".env file not found at {env_path}, skipping file update but continuing with in-memory update"
)
# Update the settings in memory (this always happens)
if refresh_token:
@@ -494,7 +481,7 @@ async def save_google_drive_settings(
return {
"status": "success",
"message": "Google Drive settings have been saved",
"in_memory_only": not env_file_written,
"in_memory_only": not os.path.exists(env_path),
}
except Exception as e:
-6
View File
@@ -17,7 +17,6 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.models import UserImapAccount
from app.utils.encryption import decrypt_value, encrypt_value
from app.utils.network import is_private_ip
from app.utils.subscription import get_tier, get_user_tier_id
from app.utils.user_scope import get_current_owner_id
@@ -188,11 +187,6 @@ def _test_imap_connection(host: str, port: int, username: str, password: str, us
Returns a dict with ``{"success": bool, "message": str}``.
"""
# Security: Prevent SSRF by blocking connections to internal IPs
if is_private_ip(host):
logger.warning("SSRF blocked: Attempt to connect to private IP %s", host)
return {"success": False, "message": "Connection error: Invalid hostname or IP address"}
try:
if use_ssl:
mail = imaplib.IMAP4_SSL(host, port)
+57 -43
View File
@@ -470,31 +470,6 @@ def delete_integration(
logger.info("User %s deleted integration %d", owner_id, integration_id)
@router.get("/{integration_id}/credentials", summary="Retrieve decrypted credentials for an integration")
def get_integration_credentials(
integration_id: int,
request: Request,
db: DbSession,
owner_id: CurrentOwner,
) -> dict[str, Any]:
"""Return the decrypted credentials dict for a saved integration.
This endpoint is intended for internal use by background tasks that need
to authenticate with a third-party service. Treat the response as
sensitive — it contains plaintext secrets.
"""
integration = (
db.query(UserIntegration)
.filter(UserIntegration.id == integration_id, UserIntegration.owner_id == owner_id)
.first()
)
if not integration:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Integration not found")
credentials = _decode_credentials(integration.credentials)
return {"credentials": credentials or {}}
# ---------------------------------------------------------------------------
# Connection test helpers
# ---------------------------------------------------------------------------
@@ -515,12 +490,6 @@ def _test_imap_connection(config: dict[str, Any] | None, credentials: dict[str,
if not host or not username or not password:
return {"success": False, "message": "Missing required fields: host, username, and password"}
from app.utils.network import is_private_ip
if is_private_ip(host):
logger.warning("SSRF blocked: Attempt to connect to private IP %s", host)
return {"success": False, "message": "Connection error: Invalid hostname or IP address"}
try:
if use_ssl:
mail = imaplib.IMAP4_SSL(host, port)
@@ -549,28 +518,17 @@ def _test_s3_connection(config: dict[str, Any] | None, credentials: dict[str, An
creds = credentials or {}
bucket = cfg.get("bucket", "")
region = cfg.get("region", "us-east-1")
endpoint_url = cfg.get("endpoint_url")
if not bucket:
return {"success": False, "message": "Missing required field: bucket"}
if endpoint_url:
from urllib.parse import urlparse
from app.utils.network import is_private_ip
parsed_url = urlparse(endpoint_url)
if parsed_url.hostname and is_private_ip(parsed_url.hostname):
logger.warning("SSRF blocked: Attempt to connect to private IP via S3 endpoint %s", endpoint_url)
return {"success": False, "message": "Connection error: Invalid endpoint URL or private IP"}
try:
client = boto3.client(
"s3",
region_name=region,
aws_access_key_id=creds.get("access_key_id", ""),
aws_secret_access_key=creds.get("secret_access_key", ""),
endpoint_url=endpoint_url,
endpoint_url=cfg.get("endpoint_url"),
)
client.head_bucket(Bucket=bucket)
return {"success": True, "message": f"S3 bucket '{bucket}' is accessible"}
@@ -679,6 +637,62 @@ _CONNECTION_TESTERS: dict[str, Any] = {
# ---------------------------------------------------------------------------
@router.post("/{integration_id}/test", summary="Test a saved integration connection")
def test_saved_integration_connection(
integration_id: int,
request: Request,
db: DbSession,
owner_id: CurrentOwner,
) -> dict[str, Any]:
"""Test connection for an already-saved integration."""
integration = (
db.query(UserIntegration)
.filter(UserIntegration.id == integration_id, UserIntegration.owner_id == owner_id)
.first()
)
if not integration:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Integration not found")
tester = _CONNECTION_TESTERS.get(integration.integration_type)
if tester is None:
return {
"success": False,
"message": f"Connection testing is not yet supported for '{integration.integration_type}'. "
"The integration can still be saved and will be validated on first use.",
}
try:
config = json.loads(integration.config) if integration.config else {}
except (json.JSONDecodeError, TypeError):
logger.warning(
"Invalid JSON in integration.config for integration_id=%s, type=%s",
integration.id,
integration.integration_type,
)
return {
"success": False,
"message": (
"Saved integration configuration is invalid and cannot be tested. "
"Please edit and re-save the integration, then try again."
),
}
credentials = _decode_credentials(integration.credentials) or {}
try:
return tester(config, credentials)
except Exception:
logger.exception(
"Unexpected error testing integration_id=%s, type=%s",
integration.id,
integration.integration_type,
)
return {
"success": False,
"message": "An unexpected error occurred while testing the connection. Please check your configuration.",
}
@router.post("/test", summary="Test an integration connection without saving")
def test_integration_connection(
request: Request,
+2 -13
View File
@@ -25,17 +25,6 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _require_admin(request: Request) -> dict:
"""Ensure the caller is an admin. Raises 403 otherwise."""
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
AdminUser = Annotated[dict, Depends(_require_admin)]
@router.post("/onedrive/exchange-token")
@require_login
async def exchange_onedrive_token(
@@ -313,15 +302,15 @@ def format_time_remaining(time_delta):
@router.post("/onedrive/save-settings")
@require_login
async def save_onedrive_settings(
request: Request,
refresh_token: Annotated[str, Form(...)],
_admin: AdminUser,
db: Session = Depends(get_db),
client_id: Annotated[Optional[str], Form()] = None,
client_secret: Annotated[Optional[str], Form()] = None,
tenant_id: Annotated[str, Form()] = "common",
folder_path: Annotated[Optional[str], Form()] = None,
db: Session = Depends(get_db),
):
"""
Saves to database (primary) and .env file (best-effort).
-33
View File
@@ -106,25 +106,6 @@ def validate_file_type(content_type: str, filename: str) -> bool:
return False
async def verify_redirect(response: httpx.Response) -> None:
"""
Event hook to intercept redirects and validate the new destination URL.
Prevents SSRF bypasses via redirects to internal networks or metadata endpoints.
"""
if response.status_code in (301, 302, 303, 307, 308):
location = response.headers.get("Location")
if location:
# Resolve relative redirects
new_url = str(response.url.join(location))
# Validate the new URL
try:
validate_url_safety(new_url)
except HTTPException as e:
# Map the validation error to an httpx exception so it can be handled
# properly by the caller, avoiding raw HTTPExceptions escaping the client scope
raise httpx.RequestError(f"Redirect to unsafe URL blocked: {e.detail}", request=response.request) from e
@router.post("/process-url")
@require_login
async def process_url(
@@ -170,19 +151,6 @@ async def process_url(
if not safe_filename:
safe_filename = "download"
# Hook to validate redirects and prevent SSRF
async def validate_redirect(response: httpx.Response):
if response.is_redirect:
location = response.headers.get("Location")
if location:
# Resolve relative URLs
next_url = urllib.parse.urljoin(str(response.url), location)
try:
validate_url_safety(next_url)
except HTTPException as e:
# Reraise as a RequestError so httpx aborts the request
raise httpx.RequestError(f"Unsafe redirect target: {e.detail}", request=response.request)
# Download file with security measures
# Initialize target_path to None to prevent UnboundLocalError in exception handlers
# that may execute before target_path is assigned during error cases
@@ -194,7 +162,6 @@ async def process_url(
async with httpx.AsyncClient(
timeout=settings.http_request_timeout,
follow_redirects=True,
event_hooks={"response": [validate_redirect, verify_redirect]},
headers={
"User-Agent": "DocuElevate/1.0", # Identify ourselves
},
+2 -8
View File
@@ -294,16 +294,10 @@ async def lifespan(app: FastAPI):
yield
# Shutdown: Cleanup tasks
try:
logging.info("Application shutting down")
except Exception:
_startup_logger.exception("Error during shutdown logging")
logging.info("Application shutting down")
# Send shutdown notification
try:
notify_shutdown()
except Exception:
_startup_logger.exception("Error sending shutdown notification")
notify_shutdown()
app = FastAPI(
+1 -1
View File
@@ -205,7 +205,7 @@ def convert_to_pdf(
".pdf", # PDF (already in PDF format but can be processed)
}
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp", ".svg"}
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp", ".svg", ".heic", ".heif"}
HTML_EXTENSIONS = {".html", ".htm"}
+1 -1
View File
@@ -78,7 +78,7 @@ def _convert_pdf_to_pdfa(input_path: str, output_path: str, pdfa_format: str = "
output_type,
"--quiet",
"--invalidate-digital-signatures",
"--", # end-of-options separator: prevents file paths from being interpreted as options
"--",
input_path,
output_path,
]
+1 -1
View File
@@ -76,7 +76,7 @@ def extract_metadata_with_gpt(self, filename: str, cleaned_text: str, file_id: i
"Your task is to analyze the given text and return a well-structured JSON object.\n\n"
"Extract and return the following fields:\n"
"1. **filename**: Machine-readable filename "
"(YYYY-MM-DD_DescriptiveTitle, use only letters, numbers, periods, and underscores).\n"
"(YYYY-MM-DD_DescriptiveTitle, use only letters, numbers, spaces, dashes, periods, and underscores).\n"
'2. **empfaenger**: The recipient, or "Unknown" if not found.\n'
'3. **absender**: The sender, or "Unknown" if not found.\n'
"4. **correspondent**: The entity or company that issued the document "
-6
View File
@@ -18,7 +18,6 @@ from app.utils.allowed_types import (
DEFAULT_CATEGORIES,
get_allowed_types_for_categories,
)
from app.utils.network import is_private_ip
# Database session for per-user IMAP accounts (imported lazily to avoid circular imports)
_db_session_factory = None
@@ -406,11 +405,6 @@ def pull_inbox(
)
processed_emails = load_processed_emails()
# Security: Prevent SSRF by blocking connections to internal IPs
if is_private_ip(host):
logger.warning("SSRF blocked: Attempt to pull mailbox from private IP %s", host)
return
try:
mail = imaplib.IMAP4_SSL(host, port) if use_ssl else imaplib.IMAP4(host, port)
mail.login(username, password)
+2 -1
View File
@@ -555,8 +555,9 @@ def _upload_rclone(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], t
dest = dest.replace("//", "/")
try:
# SECURITY: Separate options from positional arguments using -- to prevent command injection
result = subprocess.run( # nosec B603 # noqa: S603 S607
["rclone", "copyto", f"--config={conf_path}", file_path, dest], # noqa: S603 S607
["rclone", "copyto", f"--config={conf_path}", "--", file_path, dest], # noqa: S603 S607
capture_output=True,
text=True,
timeout=300,
+3 -4
View File
@@ -15,7 +15,7 @@ import logging
import re
from typing import Any
from sqlalchemy import MetaData, create_engine, func, inspect, select, table
from sqlalchemy import MetaData, create_engine, inspect, text
from sqlalchemy.engine import Engine
from sqlalchemy.engine.url import make_url
from sqlalchemy.orm import sessionmaker
@@ -89,9 +89,8 @@ def preview_migration(source_url: str) -> dict[str, Any]:
logger.warning(f"Skipping table with invalid name format: {table_name}")
continue
# table_name is safe — sourced from inspect().get_table_names(), not user input
t = table(table_name)
query = select(func.count()).select_from(t)
row = conn.execute(query).fetchone()
quoted_table = conn.dialect.identifier_preparer.quote(table_name)
row = conn.execute(text(f"SELECT COUNT(*) FROM {quoted_table}")).fetchone() # noqa: S608
count = row[0] if row else 0
result.append({"name": table_name, "row_count": count})
total += count
+2 -1
View File
@@ -1,10 +1,11 @@
import logging
import os
from typing import Dict
logger = logging.getLogger(__name__)
def update_env_file(settings_to_update: dict[str, str]) -> bool:
def update_env_file(settings_to_update: Dict[str, str]) -> bool:
"""
Updates the .env file with the given settings (best-effort).
Creates or modifies existing keys.
-26
View File
@@ -1,7 +1,6 @@
import ipaddress
import logging
import socket
from urllib.parse import urlsplit, urlunsplit
logger = logging.getLogger(__name__)
@@ -33,28 +32,3 @@ def is_private_ip(hostname: str) -> bool:
# and SSRF bypasses via unresolvable addresses.
logger.warning(f"Could not resolve hostname (blocking securely): {hostname}")
return True
def join_url(base: str, *parts: str) -> str:
"""
Safely join a base URL with one or more path parts.
Uses urllib.parse to correctly handle scheme/netloc/query/fragment so that
only the path component is modified. Leading and trailing slashes are
stripped from each part before joining, preventing double-slash sequences
at segment boundaries without touching the scheme separator or query string.
Examples:
join_url("https://example.com/dav/", "/remote/", "file.pdf")
-> "https://example.com/dav/remote/file.pdf"
"""
parsed = urlsplit(base)
# Strip each part once and filter out empty segments; use walrus operator
# to avoid calling strip twice per iteration.
stripped_parts = [s for p in parts if (s := p.strip("/"))]
base_path = parsed.path.rstrip("/")
new_path = base_path + "/" + "/".join(stripped_parts) if stripped_parts else base_path
# Ensure path is non-empty so the reconstructed URL is valid.
if not new_path:
new_path = "/"
return urlunsplit((parsed.scheme, parsed.netloc, new_path, parsed.query, parsed.fragment))
+25 -4
View File
@@ -3,7 +3,7 @@ services:
build:
context: .
dockerfile: Dockerfile
container_name: document_api
# No container_name — allows `docker compose up --scale api=N`
restart: always
# We'll keep the code in /app, but set working_dir to the shared data directory
@@ -24,7 +24,7 @@ services:
depends_on:
- redis
- worker
- beat
# Mount the shared working directory for data
volumes:
@@ -34,13 +34,14 @@ services:
build:
context: .
dockerfile: Dockerfile
container_name: document_worker
# No container_name — allows `docker compose up --scale worker=N`
restart: always
# same shared working directory
working_dir: /workdir
command: ["celery", "-A", "app.celery_worker", "worker", "-B", "--loglevel=info", "-Q", "document_processor,default,celery"]
# Workers process tasks only — no -B flag (Beat runs in the dedicated beat service)
command: ["celery", "-A", "app.celery_worker", "worker", "--loglevel=info", "-Q", "document_processor,default,celery"]
env_file:
- .env
environment:
@@ -54,6 +55,26 @@ services:
volumes:
- /var/docparse/workdir:/workdir
# Dedicated Celery Beat scheduler — exactly one instance must run at all times.
# Beat publishes periodic tasks to the Redis broker; workers pick them up.
# Do NOT scale this service (replicas must stay at 1).
beat:
build:
context: .
dockerfile: Dockerfile
container_name: document_beat
restart: always
working_dir: /workdir
command: ["celery", "-A", "app.celery_worker", "beat", "--loglevel=info"]
env_file:
- .env
environment:
- PYTHONPATH=/app
depends_on:
- redis
volumes:
- /var/docparse/workdir:/workdir
gotenberg:
image: gotenberg/gotenberg:latest
container_name: gotenberg
+6 -6
View File
@@ -536,9 +536,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -974,9 +974,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ _("billing.success_page_title") }}</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+6 -5
View File
@@ -1056,7 +1056,7 @@
html += `
<div role="listitem">
<a href="/files/${doc.file_id}/detail" aria-label="${title} — ${scorePercent}% similarity (${scoreLabel})" style="text-decoration: none; color: inherit; display: block;">
<a href="/files/${doc.file_id}" aria-label="${title} — ${scorePercent}% similarity (${scoreLabel})" style="text-decoration: none; color: inherit; display: block;">
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1rem; background-color: #f7fafc; border-radius: 0.5rem; border: 1px solid #e2e8f0; transition: border-color 0.2s; cursor: pointer;" onmouseover="this.style.borderColor='#4299e1'" onmouseout="this.style.borderColor='#e2e8f0'">
<div style="flex-shrink: 0; width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.875rem; color: white; background-color: ${scorePercent >= 80 ? '#48bb78' : scorePercent >= 50 ? '#ecc94b' : '#718096'};" aria-hidden="true">
${scorePercent}%
@@ -1091,19 +1091,20 @@
}
</script>
{% endif %}
{% endblock %}
{% block content %}
<div class="detail-container">
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
<a href="/files" class="back-button" style="margin-bottom:0;" aria-label="Back to File List">
<a href="/files/{{ file.id }}" class="back-button" style="margin-bottom:0;" aria-label="Back to File Summary">
<i class="fas fa-arrow-left" aria-hidden="true"></i>
Back to File List
Back to File Summary
</a>
{% if file %}
<a href="/files/{{ file.id }}" class="back-button" style="margin-bottom:0;" aria-label="View document for {{ file.original_filename }}">
<a href="/files/{{ file.id }}/detail" class="back-button" style="margin-bottom:0;" aria-label="View document detail for {{ file.original_filename }}">
<i class="fas fa-eye" aria-hidden="true"></i>
View Document
Document Detail
</a>
{% endif %}
</div>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Forgot Password</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Forgot Username</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+3 -15
View File
@@ -1379,27 +1379,15 @@ function integrationsDashboard() {
async testSavedIntegration(intg) {
this.testingId = intg.id;
try {
// Retrieve saved credentials to test
const credsResp = await fetch(`/api/integrations/${intg.id}/credentials`);
if (!credsResp.ok) {
this.showAlert('error', 'Test Failed', 'Could not retrieve saved credentials for testing.');
return;
}
const creds = await credsResp.json();
const resp = await fetch('/api/integrations/test', {
const resp = await fetch(`/api/integrations/${intg.id}/test`, {
method: 'POST',
headers: authHeaders(true),
body: JSON.stringify({
integration_type: intg.integration_type,
config: intg.config,
credentials: creds,
}),
});
const data = await resp.json();
if (data.success) {
if (resp.ok && data.success) {
this.showAlert('success', `${intg.name}: Connection OK`, data.message);
} else {
this.showAlert('error', `${intg.name}: Connection Failed`, data.message);
this.showAlert('error', `${intg.name}: Connection Failed`, data.message || {{ _("integrations.connection_test_failed_fallback")|tojson }});
}
} catch (err) {
this.showAlert('error', 'Test Failed', `Network error: ${err.message || 'Unknown error'}`);
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ _("app.name") }} - {{ _("auth.login_title") }}</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Reset Password</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<title>Shared Document DocuElevate</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Tailwind CSS -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
<!-- Tailwind CSS v3 (compiled) -->
<link rel="stylesheet" href="/static/styles.css" />
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ _("auth.verify_email_page_title") }}</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+1
View File
@@ -1107,6 +1107,7 @@
"integrations.connected": "Connected",
"integrations.connection_failed": "Connection failed",
"integrations.connection_success": "Connection successful",
"integrations.connection_test_failed_fallback": "Connection test failed.",
"integrations.delete_confirm_are_you_sure": "Are you sure you want to delete",
"integrations.delete_confirm_title": "Delete Integration?",
"integrations.delete_confirm_undone": "This action cannot be undone.",
@@ -0,0 +1,83 @@
{{- /*
Celery Beat scheduler — publishes periodic tasks to the broker.
Exactly ONE replica must run; never scale this deployment.
*/ -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "docuelevate.fullname" . }}-beat
namespace: {{ .Release.Namespace }}
labels:
{{- include "docuelevate.labels" . | nindent 4 }}
app.kubernetes.io/component: beat
spec:
replicas: 1
strategy:
type: Recreate # Prevent two Beat instances from running simultaneously
selector:
matchLabels:
{{- include "docuelevate.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: beat
template:
metadata:
labels:
{{- include "docuelevate.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: beat
{{- with .Values.beat.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
serviceAccountName: {{ include "docuelevate.serviceAccountName" . }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.beat.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: beat
image: {{ include "docuelevate.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command:
- celery
- -A
- app.celery_worker
- beat
- --loglevel=info
envFrom:
- configMapRef:
name: {{ include "docuelevate.fullname" . }}-config
- secretRef:
name: {{ include "docuelevate.fullname" . }}-secret
{{- with .Values.beat.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.beat.resources | nindent 12 }}
volumeMounts:
- name: workdir
mountPath: /workdir
volumes:
- name: workdir
{{- if .Values.workdir.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Values.workdir.persistence.existingClaim | default (printf "%s-workdir" (include "docuelevate.fullname" .)) }}
{{- else }}
emptyDir: {}
{{- end }}
{{- with .Values.beat.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.beat.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.beat.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -42,7 +42,6 @@ spec:
- -A
- app.celery_worker
- worker
- -B
- --loglevel=info
- -Q
- document_processor,default,celery
+33 -4
View File
@@ -121,10 +121,10 @@ api:
type: ClusterIP
port: 8000
# Liveness / readiness probes
# Liveness / readiness probes (unauthenticated endpoints for kubelet)
livenessProbe:
httpGet:
path: /api/health
path: /api/diagnostic/healthz/live
port: 8000
initialDelaySeconds: 30
periodSeconds: 20
@@ -132,7 +132,7 @@ api:
readinessProbe:
httpGet:
path: /api/health
path: /api/diagnostic/healthz/ready
port: 8000
initialDelaySeconds: 15
periodSeconds: 10
@@ -191,7 +191,36 @@ worker:
drop: ["ALL"]
# ---------------------------------------------------------------------------
# Shared workdir volume (api + worker mount the same PVC)
# Celery Beat scheduler (singleton — always exactly 1 replica)
# Beat publishes periodic tasks; workers consume them from the broker.
# ---------------------------------------------------------------------------
beat:
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop: ["ALL"]
# ---------------------------------------------------------------------------
# Shared workdir volume (api + worker + beat mount the same PVC)
# ---------------------------------------------------------------------------
workdir:
persistence:
-201
View File
@@ -1,201 +0,0 @@
"""
Base setup for views, containing shared functionality and imports.
"""
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request # noqa: F401
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session # noqa: F401
from app.auth import require_login # noqa: F401
from app.config import settings
from app.database import SessionLocal, get_db # noqa: F401
from app.models import UserProfile
from app.utils.i18n import (
SUPPORTED_LANGUAGES,
detect_language,
format_date,
format_datetime,
format_number,
get_suggested_languages,
translate,
)
# Set up Jinja2 templates
templates_dir = Path(__file__).parent.parent.parent / "frontend" / "templates"
templates = Jinja2Templates(directory=str(templates_dir))
# Add Python built-in functions to Jinja2 template globals
templates.env.globals["min"] = min
templates.env.globals["max"] = max
# ---------------------------------------------------------------------------
# i18n Jinja2 integration
# ---------------------------------------------------------------------------
# The _() function is available in every template to translate UI strings.
# Usage: {{ _("nav.dashboard") }} or {{ _("upload.max_size", size="10 MB") }}
# The locale is automatically resolved from the request context.
# A default English implementation is registered as a global so error handlers
# that don't go through _inject_global_context still have the function available.
# ---------------------------------------------------------------------------
templates.env.globals["supported_languages"] = SUPPORTED_LANGUAGES
templates.env.globals["_"] = lambda key, **kwargs: translate(key, "en", **kwargs)
# Customize Jinja2Templates to include app_version in all templates
original_template_response = templates.TemplateResponse
def _hydrate_language_from_db(request: Request, session_user: object) -> None:
"""Load the user's preferred language from the DB into the session.
Called once per session when ``preferred_language`` is not yet in the
session. A lightweight DB query fetches the stored preference so that
:func:`detect_language` picks it up from the session on all subsequent
requests without further DB access.
"""
from app.utils.i18n import SUPPORTED_LANGUAGE_CODES
user_id: str | None = None
if isinstance(session_user, dict):
user_id = (
session_user.get("sub")
or session_user.get("preferred_username")
or session_user.get("email")
or session_user.get("id")
)
elif isinstance(session_user, str):
user_id = session_user
if not user_id:
return
db = SessionLocal()
try:
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if profile and profile.preferred_language and profile.preferred_language in SUPPORTED_LANGUAGE_CODES:
request.session["preferred_language"] = profile.preferred_language
except Exception: # noqa: BLE001 — intentionally broad; DB may be temporarily unavailable
logger.debug("Could not hydrate language preference for user_id=%s", user_id)
finally:
db.close()
def _inject_global_context(ctx: dict) -> None:
"""Inject shared global variables into every template context dict."""
ctx.setdefault("version", settings.version)
ctx.setdefault("release_name", getattr(settings, "release_name", None))
ctx.setdefault("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system"))
ctx.setdefault("multi_user_enabled", getattr(settings, "multi_user_enabled", False))
ctx.setdefault("auth_enabled", getattr(settings, "auth_enabled", True))
ctx.setdefault(
"allow_signup",
getattr(settings, "multi_user_enabled", False) and getattr(settings, "allow_local_signup", False),
)
ctx.setdefault("enable_factory_reset", getattr(settings, "enable_factory_reset", False))
# Sentry Browser SDK config (injected into every page so the JS SDK can initialise)
# Normalize empty-string DSN to None so the {% if sentry_dsn %} template guard works correctly.
_raw_dsn = getattr(settings, "sentry_dsn", None)
ctx.setdefault("sentry_dsn", _raw_dsn if _raw_dsn else None)
ctx.setdefault("sentry_environment", getattr(settings, "sentry_environment", "production"))
ctx.setdefault("sentry_js_traces_sample_rate", getattr(settings, "sentry_js_traces_sample_rate", 0.0))
ctx.setdefault(
"sentry_js_replay_session_sample_rate",
getattr(settings, "sentry_js_replay_session_sample_rate", 0.0),
)
ctx.setdefault(
"sentry_js_replay_on_error_sample_rate",
getattr(settings, "sentry_js_replay_on_error_sample_rate", 0.1),
)
req = ctx.get("request")
if req is not None:
# CSRF token
if hasattr(req, "state") and hasattr(req.state, "csrf_token"):
ctx.setdefault("csrf_token", req.state.csrf_token)
# Determine whether the current visitor is authenticated
session_user = None
if hasattr(req, "session"):
session_user = req.session.get("user")
# When auth is disabled every visitor is effectively "logged in"
ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True) or session_user is not None)
# --- Hydrate session language from DB (once per session) ---
# If the session doesn't have a preferred_language yet but the user
# is logged in, load the stored preference from the database so that
# detect_language() picks it up from the session on this and all
# subsequent requests.
if hasattr(req, "session") and "preferred_language" not in req.session and session_user is not None:
_hydrate_language_from_db(req, session_user)
# --- i18n: detect language and register template helpers ---
current_locale = detect_language(req)
ctx.setdefault("current_locale", current_locale)
# Smart language suggestions for the compact nav-bar dropdown (5-7 languages)
accept_header = req.headers.get("accept-language", "") if hasattr(req, "headers") else ""
ctx.setdefault("suggested_languages", get_suggested_languages(current_locale, accept_header))
def _translate(key: str, **kwargs: object) -> str:
return translate(key, current_locale, **kwargs)
def _format_date(value: object, short: bool = False) -> str:
return format_date(value, current_locale, short=short) # type: ignore[arg-type]
def _format_datetime(value: object) -> str:
return format_datetime(value, current_locale) # type: ignore[arg-type]
def _format_number(value: object) -> str:
return format_number(value, current_locale) # type: ignore[arg-type]
ctx.setdefault("_", _translate)
ctx.setdefault("format_date_l10n", _format_date)
ctx.setdefault("format_datetime_l10n", _format_datetime)
ctx.setdefault("format_number_l10n", _format_number)
else:
ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True))
ctx.setdefault("current_locale", "en")
ctx.setdefault("_", lambda key, **kw: translate(key, "en", **kw))
def template_response_with_version(*args, **kwargs):
"""Wrapper for TemplateResponse to include version and CSRF token in all templates.
Handles both old-style and new-style Starlette TemplateResponse calls:
- Old-style (Starlette <1.0): TemplateResponse(name, {"request": req, ...}, ...)
- New-style (Starlette 1.0+): TemplateResponse(request, name, context={...}, ...)
"""
if len(args) >= 1 and isinstance(args[0], str):
# Old-style call: first positional arg is the template name (string).
# Convert to new-style: (request, name, context=..., ...)
name = args[0]
if len(args) >= 2 and isinstance(args[1], dict):
context = args[1]
# Old-style may have status_code as 3rd positional arg
if len(args) >= 3 and "status_code" not in kwargs:
kwargs["status_code"] = args[2]
else:
context = kwargs.pop("context", {})
request_obj = context.pop("request", None)
if request_obj is not None:
context["request"] = request_obj
_inject_global_context(context)
if request_obj is not None:
return original_template_response(request_obj, name, context=context, **kwargs)
return original_template_response(name, context=context, **kwargs)
# New-style call: (request, name, context=..., ...)
if "context" in kwargs and isinstance(kwargs["context"], dict):
_inject_global_context(kwargs["context"])
elif len(args) >= 3 and isinstance(args[2], dict):
_inject_global_context(args[2])
return original_template_response(*args, **kwargs)
templates.TemplateResponse = template_response_with_version
# Set up logging
logger = logging.getLogger(__name__)
-34
View File
@@ -1,34 +0,0 @@
import sys
from unittest.mock import MagicMock
from fastapi.templating import Jinja2Templates
import os
# We don't really need a real path, but let's mock it
os.makedirs("templates", exist_ok=True)
with open("templates/files.html", "w") as f:
f.write("Hello")
templates = Jinja2Templates(directory="templates")
original_template_response = templates.TemplateResponse
def template_response_with_version(*args, **kwargs):
if len(args) == 2 and isinstance(args[0], str) and isinstance(args[1], dict):
context = args[1]
request = context.get("request")
if request is not None:
# THIS IS MY FIX
print("Running fix logic")
return original_template_response(request=request, name=args[0], context=context, **kwargs)
print("Running original fallback logic")
return original_template_response(*args, **kwargs)
templates.TemplateResponse = template_response_with_version
req = MagicMock()
try:
templates.TemplateResponse("files.html", {"request": req})
print("SUCCESS")
except Exception as e:
import traceback
traceback.print_exc()
-33
View File
@@ -1,33 +0,0 @@
import sys
from unittest.mock import MagicMock
from fastapi.templating import Jinja2Templates
import os
os.makedirs("templates", exist_ok=True)
with open("templates/files.html", "w") as f:
f.write("Hello")
templates = Jinja2Templates(directory="templates")
original_template_response = templates.TemplateResponse
def template_response_with_version(*args, **kwargs):
if len(args) == 2 and isinstance(args[0], str) and isinstance(args[1], dict):
context = args[1]
request = context.get("request")
if request is not None:
# THIS IS MY FIX
print("Running fix logic")
return original_template_response(request=request, name=args[0], context=context, **kwargs)
print("Running original fallback logic", args, kwargs)
return original_template_response(*args, **kwargs)
templates.TemplateResponse = template_response_with_version
req = MagicMock()
try:
templates.TemplateResponse(request=req, name="files.html", context={"request": req})
print("SUCCESS")
except Exception as e:
import traceback
traceback.print_exc()
-33
View File
@@ -1,33 +0,0 @@
import sys
from unittest.mock import MagicMock
from fastapi.templating import Jinja2Templates
import os
os.makedirs("templates", exist_ok=True)
with open("templates/files.html", "w") as f:
f.write("Hello")
templates = Jinja2Templates(directory="templates")
original_template_response = templates.TemplateResponse
def template_response_with_version(*args, **kwargs):
if len(args) == 2 and isinstance(args[0], str) and isinstance(args[1], dict):
context = args[1]
request = context.get("request")
if request is not None:
# THIS IS MY FIX
print("Running fix logic")
return original_template_response(request=request, name=args[0], context=context, **kwargs)
print("Running original fallback logic", args, kwargs)
return original_template_response(*args, **kwargs)
templates.TemplateResponse = template_response_with_version
req = MagicMock()
try:
templates.TemplateResponse("files.html", {"request": req}, status_code=200)
print("SUCCESS")
except Exception as e:
import traceback
traceback.print_exc()
-35
View File
@@ -1,35 +0,0 @@
import sys
from unittest.mock import MagicMock
from fastapi.templating import Jinja2Templates
import os
os.makedirs("templates", exist_ok=True)
with open("templates/files.html", "w") as f:
f.write("Hello")
templates = Jinja2Templates(directory="templates")
original_template_response = templates.TemplateResponse
def template_response_with_version(*args, **kwargs):
print("ARGS:", args)
print("KWARGS:", kwargs)
if len(args) == 2 and isinstance(args[0], str) and isinstance(args[1], dict):
context = args[1]
request = context.get("request")
if request is not None:
# THIS IS MY FIX
print("Running fix logic")
return original_template_response(request=request, name=args[0], context=context, **kwargs)
print("Running original fallback logic", args, kwargs)
return original_template_response(*args, **kwargs)
templates.TemplateResponse = template_response_with_version
req = MagicMock()
try:
templates.TemplateResponse("files.html", context={"request": req})
print("SUCCESS")
except Exception as e:
import traceback
traceback.print_exc()
+1 -1
View File
@@ -34,7 +34,7 @@ pip-audit>=2.7.0 # Dependency vulnerability scanning against OSV/PyPA advisory
pre-commit>=3.6.0
# License compliance
pip-licenses==5.5.5 # For license compliance checking
pip-licenses==5.5.1 # For license compliance checking
# Release automation
python-semantic-release>=9.0.0
+1 -1
View File
@@ -51,7 +51,7 @@ pytesseract>=0.3.10 # Python wrapper for Tesseract OCR
pdf2image>=1.17.0 # Convert PDF pages to images (used by Tesseract and EasyOCR providers)
ocrmypdf>=16.0.0,<18.0.0 # Post-processing: embeds searchable text layers into PDFs via Tesseract
meilisearch>=0.31.0 # Full-text search engine client
stripe>=7.0.0,<16.0.0 # Stripe billing SDK (MIT license)
stripe>=7.0.0,<15.0.0 # Stripe billing SDK (MIT license)
# Error and performance monitoring
sentry-sdk[fastapi,celery,sqlalchemy]>=2.20.0,<3.0.0
-1
View File
@@ -1 +0,0 @@
Hello
+2
View File
@@ -83,6 +83,8 @@ class TestGotenbergCoverageDocuments:
".tif",
".webp",
".svg",
".heic",
".heif",
}
_html_extensions = {".html", ".htm"}
_markdown_extensions = {".md", ".markdown"}
-9
View File
@@ -267,15 +267,6 @@ class TestTestDropboxToken:
class TestSaveDropboxSettings:
"""Tests for save_dropbox_settings endpoint."""
@pytest.fixture(autouse=True)
def _admin_override(self):
from app.api.dropbox import _require_admin
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
yield
fastapi_app.dependency_overrides.pop(_require_admin, None)
@patch("app.api.dropbox.settings")
def test_save_settings_env_not_found(self, mock_settings, client):
"""Test that missing .env file is non-fatal — DB write still succeeds."""
+8 -16
View File
@@ -360,15 +360,6 @@ class TestFormatTimeRemaining:
class TestSaveGoogleDriveSettings:
"""Tests for POST /google-drive/save-settings endpoint."""
@pytest.fixture(autouse=True)
def _admin_override(self):
from app.api.google_drive import _require_admin
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
yield
fastapi_app.dependency_overrides.pop(_require_admin, None)
@patch("builtins.open", new_callable=mock_open, read_data="# Existing config\n")
@patch("os.path.exists")
@patch("os.path.dirname")
@@ -490,17 +481,18 @@ class TestSaveGoogleDriveSettings:
assert response.status_code == 200
@patch("app.api.google_drive.os")
@patch("os.path.exists")
@patch("os.path.dirname")
@patch("app.config.settings")
def test_save_settings_exception_handling(self, mock_settings, mock_os, client: TestClient):
"""Test that exceptions in .env write are non-fatal — DB write still succeeds."""
mock_os.path.exists.side_effect = Exception("Unexpected error")
def test_save_settings_exception_handling(self, mock_settings, mock_dirname, mock_exists, client: TestClient):
"""Test exception handling in save settings."""
mock_exists.side_effect = Exception("Unexpected error")
response = client.post("/api/google-drive/save-settings", data={"refresh_token": "token", "use_oauth": "true"})
# .env write exception is caught; endpoint succeeds via DB write
assert response.status_code == 200
assert response.json()["status"] == "success"
assert response.status_code == 500
data = response.json()
assert "failed to save" in data["detail"].lower()
@pytest.mark.unit
-9
View File
@@ -195,15 +195,6 @@ class TestGetGoogleDriveTokenInfo:
class TestSaveGoogleDriveSettings:
"""Test save_google_drive_settings endpoint edge cases."""
@pytest.fixture(autouse=True)
def _admin_override(self):
from app.api.google_drive import _require_admin
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
yield
fastapi_app.dependency_overrides.pop(_require_admin, None)
@patch("app.api.google_drive.settings")
@patch("os.path.exists")
def test_save_settings_env_file_not_exists(self, mock_exists, mock_settings, client: TestClient):
+4 -10
View File
@@ -152,16 +152,11 @@ class TestGetTokenInfoCredentialsBranches:
@pytest.mark.unit
class TestSaveGoogleDriveSettingsFalsyFields:
"""Cover branches 395->397, 449->451, 468->470 in save_google_drive_settings."""
"""Cover branches 395->397, 449->451, 468->470 in save_google_drive_settings.
@pytest.fixture(autouse=True)
def _admin_override(self):
from app.api.google_drive import _require_admin
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
yield
fastapi_app.dependency_overrides.pop(_require_admin, None)
Note: the Google Drive save endpoint is named save_google_drive_settings in the
source (app/api/google_drive.py).
"""
@patch("app.api.google_drive.settings")
@patch("os.path.exists", return_value=False)
@@ -182,7 +177,6 @@ class TestSaveGoogleDriveSettingsFalsyFields:
with patch("app.api.google_drive.notify_settings_updated"):
result = await save_google_drive_settings(
request=mock_request,
_admin={"is_admin": True},
refresh_token="", # falsy → branches 395->397 and 449->451
client_id="cid",
client_secret=None,
+3 -12
View File
@@ -524,10 +524,7 @@ class TestTestImapConnection:
from app.api.imap_accounts import _test_imap_connection
mock_mail = MagicMock()
with (
patch("app.api.imap_accounts.is_private_ip", return_value=False),
patch("imaplib.IMAP4_SSL", return_value=mock_mail),
):
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
result = _test_imap_connection(
"imap.example.com",
993,
@@ -544,10 +541,7 @@ class TestTestImapConnection:
"""An exception raised by IMAP4_SSL returns success=False."""
from app.api.imap_accounts import _test_imap_connection
with (
patch("app.api.imap_accounts.is_private_ip", return_value=False),
patch("imaplib.IMAP4_SSL", side_effect=Exception("auth failed")),
):
with patch("imaplib.IMAP4_SSL", side_effect=Exception("auth failed")):
result = _test_imap_connection(
"imap.example.com",
993,
@@ -563,10 +557,7 @@ class TestTestImapConnection:
"""An OSError returns success=False with a network error message."""
from app.api.imap_accounts import _test_imap_connection
with (
patch("app.api.imap_accounts.is_private_ip", return_value=False),
patch("imaplib.IMAP4", side_effect=OSError("connection refused")),
):
with patch("imaplib.IMAP4", side_effect=OSError("connection refused")):
result = _test_imap_connection(
"bad-host",
143,
+58 -48
View File
@@ -1,6 +1,7 @@
"""Tests for the per-user integrations API (app/api/integrations.py)."""
import unittest.mock
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
@@ -405,32 +406,33 @@ class TestDeleteIntegration:
@pytest.mark.integration
class TestGetIntegrationCredentials:
"""Tests for GET /api/integrations/{id}/credentials."""
class TestTestSavedIntegrationConnection:
"""Tests for POST /api/integrations/{id}/test."""
@patch("app.api.integrations._CONNECTION_TESTERS")
def test_test_saved_integration_success(self, mock_testers, int_client):
"""Test a saved integration successfully."""
mock_tester = MagicMock(return_value={"success": True, "message": "OK"})
mock_testers.get.return_value = mock_tester
def test_returns_decrypted_credentials(self, int_client):
"""Credentials endpoint returns the decrypted dict."""
created = int_client.post("/api/integrations/", json=_IMAP_SOURCE).json()
resp = int_client.get(f"/api/integrations/{created['id']}/credentials")
assert resp.status_code == 200
creds = resp.json()["credentials"]
assert creds["password"] == "s3cr3t" # noqa: S105
resp = int_client.post(f"/api/integrations/{created['id']}/test")
def test_returns_empty_dict_when_no_credentials(self, int_client):
"""No credentials stored returns empty dict."""
payload = dict(_IMAP_SOURCE, credentials=None)
created = int_client.post("/api/integrations/", json=payload).json()
resp = int_client.get(f"/api/integrations/{created['id']}/credentials")
assert resp.status_code == 200
assert resp.json()["credentials"] == {}
assert resp.json()["success"] is True
mock_testers.get.assert_called_with("IMAP")
mock_tester.assert_called_once()
args = mock_tester.call_args[0]
assert args[0]["host"] == "imap.gmail.com"
assert args[1]["password"] == "s3cr3t"
def test_not_found(self, int_client):
"""Non-existent integration returns 404."""
resp = int_client.get("/api/integrations/9999/credentials")
resp = int_client.post("/api/integrations/9999/test")
assert resp.status_code == 404
def test_other_users_credentials_returns_404(self, int_client, int_session):
"""Cannot retrieve another user's credentials."""
def test_other_users_integration_returns_404(self, int_client, int_session):
"""Cannot test another user's integration."""
other_integration = UserIntegration(
owner_id=_OTHER_OWNER,
direction="SOURCE",
@@ -441,9 +443,47 @@ class TestGetIntegrationCredentials:
)
int_session.add(other_integration)
int_session.commit()
resp = int_client.get(f"/api/integrations/{other_integration.id}/credentials")
resp = int_client.post(f"/api/integrations/{other_integration.id}/test")
assert resp.status_code == 404
@patch("app.api.integrations._CONNECTION_TESTERS")
def test_invalid_json_config_returns_failure(self, mock_testers, int_client, int_session):
"""Invalid JSON in config returns a controlled failure response."""
mock_tester = MagicMock()
mock_testers.get.return_value = mock_tester
bad_integration = UserIntegration(
owner_id=_OWNER,
direction="SOURCE",
integration_type="IMAP",
name="Bad Config",
config="not-valid-json{{{",
credentials="{}",
is_active=True,
)
int_session.add(bad_integration)
int_session.commit()
resp = int_client.post(f"/api/integrations/{bad_integration.id}/test")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "invalid" in data["message"].lower()
mock_tester.assert_not_called()
@patch("app.api.integrations._CONNECTION_TESTERS")
def test_tester_raises_exception_returns_failure(self, mock_testers, int_client):
"""Tester that raises an exception returns a controlled failure response."""
mock_testers.get.return_value = MagicMock(side_effect=ValueError("bad port"))
created = int_client.post("/api/integrations/", json=_IMAP_SOURCE).json()
resp = int_client.post(f"/api/integrations/{created['id']}/test")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "unexpected error" in data["message"].lower()
@pytest.mark.unit
class TestIntegrationModel:
@@ -998,23 +1038,6 @@ class TestConnectionTestEndpoint:
assert data["success"] is False
assert "Missing" in data["message"]
def test_test_imap_blocks_private_ip(self, int_client):
"""IMAP test with private IP returns failure (SSRF protection)."""
payload = {
"integration_type": "IMAP",
"config": {
"host": "127.0.0.1",
"port": 993,
"username": "user",
},
"credentials": {"password": "pass"},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "Invalid hostname or IP address" in data["message"]
def test_test_s3_missing_bucket(self, int_client):
"""S3 test with missing bucket returns failure."""
payload = {
@@ -1028,19 +1051,6 @@ class TestConnectionTestEndpoint:
assert data["success"] is False
assert "bucket" in data["message"].lower()
def test_test_s3_blocks_private_ip(self, int_client):
"""S3 test with private IP endpoint returns failure (SSRF protection)."""
payload = {
"integration_type": "S3",
"config": {"bucket": "my-bucket", "endpoint_url": "http://127.0.0.1:9000"},
"credentials": {"access_key_id": "AKIA", "secret_access_key": "secret"},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "Invalid endpoint URL or private IP" in data["message"]
def test_test_webdav_missing_url(self, int_client):
"""WebDAV test with missing URL returns failure."""
payload = {
-9
View File
@@ -342,15 +342,6 @@ class TestFormatTimeRemaining:
class TestSaveOneDriveSettings:
"""Tests for POST /onedrive/save-settings endpoint."""
@pytest.fixture(autouse=True)
def _admin_override(self):
from app.api.onedrive import _require_admin
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
yield
fastapi_app.dependency_overrides.pop(_require_admin, None)
@patch("builtins.open", new_callable=mock_open, read_data="# Existing config\n")
@patch("os.path.exists")
@patch("os.path.dirname")
-9
View File
@@ -294,15 +294,6 @@ class TestTokenRotationEnvAppendLine:
class TestSaveSettingsException:
"""Cover lines 324-326: save_onedrive_settings outer exception handler."""
@pytest.fixture(autouse=True)
def _admin_override(self):
from app.api.onedrive import _require_admin
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
yield
fastapi_app.dependency_overrides.pop(_require_admin, None)
def test_save_settings_outer_exception(self, client: TestClient):
"""Trigger the outer exception handler in save_onedrive_settings."""
with patch("app.api.onedrive.notify_settings_updated", side_effect=Exception("Unexpected boom")):
-21
View File
@@ -47,27 +47,6 @@ class TestProcessEndpoints:
data = response.json()
assert data["task_id"] == "test-task-id"
assert data["status"] == "queued"
mock_task.delay.assert_called_once_with(str(test_file))
def test_send_to_dropbox_endpoint_file_not_found(self, client):
"""Test POST /api/send_to_dropbox/ directly mapping to endpoint name with non-existent file."""
response = client.post("/api/send_to_dropbox/?file_path=nonexistent_endpoint.pdf")
assert response.status_code == 400
def test_send_to_dropbox_endpoint_success(self, client, tmp_path):
"""Test POST /api/send_to_dropbox/ directly mapping to endpoint name with existing file."""
test_file = tmp_path / "processed" / "test_endpoint.pdf"
test_file.parent.mkdir(parents=True)
test_file.write_text("test content endpoint")
with patch("app.api.process.upload_to_dropbox") as mock_task:
mock_task.delay.return_value = Mock(id="test-task-id-endpoint")
response = client.post(f"/api/send_to_dropbox/?file_path={test_file}")
assert response.status_code == 200
data = response.json()
assert data["task_id"] == "test-task-id-endpoint"
assert data["status"] == "queued"
mock_task.delay.assert_called_once_with(str(test_file))
def test_send_to_paperless_file_not_found(self, client):
"""Test POST /api/send_to_paperless/ with non-existent file."""
+160 -337
View File
@@ -1,368 +1,191 @@
"""Tests for the saved searches API (app/api/saved_searches.py)."""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import SavedSearch
# ---------------------------------------------------------------------------
# Saved searches CRUD tests
# Test data constants
# ---------------------------------------------------------------------------
_OWNER = "test_user@example.com"
_OTHER_OWNER = "other_user@example.com"
# ---------------------------------------------------------------------------
# Shared fixture helpers
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSavedSearchesCRUD:
"""Tests for saved searches CRUD API endpoints."""
@pytest.fixture()
def int_engine():
"""In-memory SQLite engine for integration tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
def test_list_saved_searches_empty(self, client: TestClient):
"""GET /api/saved-searches returns empty list when no searches exist."""
response = client.get("/api/saved-searches")
assert response.status_code == 200
assert response.json() == []
def test_create_saved_search(self, client: TestClient):
"""POST /api/saved-searches creates a new saved search."""
payload = {
"name": "My Invoices",
"filters": {"tags": "invoice", "status": "completed"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 201
data = response.json()
@pytest.fixture()
def int_session(int_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=int_engine)
session = Session()
yield session
session.close()
def _make_client(int_engine, owner_id: str = _OWNER):
"""Return a TestClient with *owner_id* injected as the authenticated user."""
from unittest.mock import patch
from app.main import app
def override_db():
Session = sessionmaker(bind=int_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
with patch("app.api.saved_searches._get_user_id", return_value=owner_id):
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
yield client
app.dependency_overrides.clear()
@pytest.fixture()
def int_client(int_engine):
"""TestClient authenticated as _OWNER."""
yield from _make_client(int_engine, _OWNER)
# ---------------------------------------------------------------------------
# CRUD tests
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestSavedSearchesAPI:
"""Tests for Saved Searches endpoints."""
def test_list_saved_searches_empty(self, int_client):
"""No saved searches returns empty list."""
resp = int_client.get("/api/saved-searches")
assert resp.status_code == 200
assert resp.json() == []
def test_create_saved_search(self, int_client):
"""Create a saved search and verify the response."""
payload = {"name": "My Invoices", "filters": {"tags": "invoice", "document_type": "Invoice"}}
resp = int_client.post("/api/saved-searches", json=payload)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "My Invoices"
assert data["filters"]["tags"] == "invoice"
assert data["filters"]["status"] == "completed"
assert data["filters"] == {"tags": "invoice", "document_type": "Invoice"}
assert "id" in data
def test_create_and_list_saved_search(self, client: TestClient):
"""Creating a saved search makes it appear in the list."""
payload = {
"name": "PDF Files",
"filters": {"mime_type": "application/pdf"},
}
client.post("/api/saved-searches", json=payload)
def test_create_saved_search_invalid_filters(self, int_client):
"""Creating with invalid filters returns 422."""
# Missing filters parameter (or empty after sanitization)
payload = {"name": "My Invoices", "filters": {}}
resp = int_client.post("/api/saved-searches", json=payload)
assert resp.status_code == 422
response = client.get("/api/saved-searches")
assert response.status_code == 200
searches = response.json()
assert len(searches) == 1
assert searches[0]["name"] == "PDF Files"
# Invalid filters format
payload2 = {"name": "My Invoices", "filters": "not_a_dict"}
resp2 = int_client.post("/api/saved-searches", json=payload2)
assert resp2.status_code == 422
def test_create_saved_search_missing_name(self, client: TestClient):
"""POST /api/saved-searches without name returns 422."""
payload = {"filters": {"status": "completed"}}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 422
def test_create_saved_search_duplicate(self, int_client):
"""Creating a duplicate named search returns 409."""
payload = {"name": "Duplicate", "filters": {"q": "test"}}
int_client.post("/api/saved-searches", json=payload)
resp = int_client.post("/api/saved-searches", json=payload)
assert resp.status_code == 409
def test_create_saved_search_empty_filters(self, client: TestClient):
"""POST /api/saved-searches with empty filters returns 422."""
payload = {"name": "Empty", "filters": {}}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 422
def test_create_saved_search_limit(self, int_client, int_session):
"""Exceeding MAX_SAVED_SEARCHES_PER_USER returns 409."""
# Create 50 searches using the API to ensure they are visible
for i in range(50):
resp = int_client.post("/api/saved-searches", json={"name": f"Search LIMIT {i}", "filters": {"q": "test"}})
assert resp.status_code == 201
def test_create_saved_search_invalid_filter_keys(self, client: TestClient):
"""POST /api/saved-searches ignores unknown filter keys."""
payload = {
"name": "With unknown keys",
"filters": {"invalid_key": "value", "status": "completed"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 201
data = response.json()
# Only valid filter key should remain
assert "invalid_key" not in data["filters"]
assert data["filters"]["status"] == "completed"
payload = {"name": "One too many", "filters": {"q": "test"}}
resp = int_client.post("/api/saved-searches", json=payload)
assert resp.status_code == 409
def test_create_saved_search_only_invalid_keys(self, client: TestClient):
"""POST with only invalid filter keys returns 422."""
payload = {
"name": "All invalid",
"filters": {"bad_key": "value"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 422
def test_update_saved_search(self, int_client):
"""Update an existing saved search."""
payload = {"name": "Original Name", "filters": {"q": "test"}}
created = int_client.post("/api/saved-searches", json=payload).json()
search_id = created["id"]
def test_create_duplicate_name(self, client: TestClient):
"""POST /api/saved-searches with duplicate name returns 409."""
payload = {"name": "My Search", "filters": {"status": "completed"}}
response1 = client.post("/api/saved-searches", json=payload)
assert response1.status_code == 201
update_payload = {"name": "Updated Name", "filters": {"tags": "new"}}
resp = int_client.put(f"/api/saved-searches/{search_id}", json=update_payload)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "Updated Name"
assert data["filters"] == {"tags": "new"}
response2 = client.post("/api/saved-searches", json=payload)
assert response2.status_code == 409
def test_update_saved_search_not_found(self, int_client):
"""Updating a non-existent search returns 404."""
update_payload = {"name": "Updated Name"}
resp = int_client.put("/api/saved-searches/999", json=update_payload)
assert resp.status_code == 404
def test_update_saved_search(self, client: TestClient):
"""PUT /api/saved-searches/{id} updates the saved search."""
# Create
create_resp = client.post(
"/api/saved-searches",
json={"name": "Original", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
def test_update_saved_search_duplicate_name(self, int_client):
"""Updating name to an existing search name returns 409."""
payload1 = {"name": "Search 1", "filters": {"q": "a"}}
payload2 = {"name": "Search 2", "filters": {"q": "b"}}
int_client.post("/api/saved-searches", json=payload1)
created2 = int_client.post("/api/saved-searches", json=payload2).json()
search2_id = created2["id"]
# Update
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "Updated", "filters": {"status": "completed"}},
)
assert update_resp.status_code == 200
data = update_resp.json()
assert data["name"] == "Updated"
assert data["filters"]["status"] == "completed"
update_payload = {"name": "Search 1"}
resp = int_client.put(f"/api/saved-searches/{search2_id}", json=update_payload)
assert resp.status_code == 409
def test_update_saved_search_not_found(self, client: TestClient):
"""PUT /api/saved-searches/999 returns 404."""
response = client.put(
"/api/saved-searches/999",
json={"name": "Nope", "filters": {"status": "completed"}},
)
assert response.status_code == 404
def test_delete_saved_search(self, int_client, int_session):
"""Delete an existing search."""
payload = {"name": "To be deleted", "filters": {"q": "test"}}
created = int_client.post("/api/saved-searches", json=payload).json()
search_id = created["id"]
def test_delete_saved_search(self, client: TestClient):
"""DELETE /api/saved-searches/{id} removes the saved search."""
# Create
create_resp = client.post(
"/api/saved-searches",
json={"name": "To Delete", "filters": {"status": "failed"}},
)
search_id = create_resp.json()["id"]
resp = int_client.delete(f"/api/saved-searches/{search_id}")
assert resp.status_code == 204
# Delete
del_resp = client.delete(f"/api/saved-searches/{search_id}")
assert del_resp.status_code == 204
assert int_session.query(SavedSearch).filter(SavedSearch.id == search_id).first() is None
# Verify it's gone
list_resp = client.get("/api/saved-searches")
assert len(list_resp.json()) == 0
def test_delete_saved_search_not_found(self, int_client):
"""Deleting a non-existent search returns 404."""
resp = int_client.delete("/api/saved-searches/999")
assert resp.status_code == 404
def test_delete_saved_search_not_found(self, client: TestClient):
"""DELETE /api/saved-searches/999 returns 404."""
response = client.delete("/api/saved-searches/999")
assert response.status_code == 404
def test_other_users_searches_isolated(self, int_engine, int_session):
"""Users only see and can only modify their own saved searches."""
int_session.add(SavedSearch(user_id=_OTHER_OWNER, name="Other Search", filters='{"q": "test"}'))
int_session.commit()
def test_create_name_too_long(self, client: TestClient):
"""POST /api/saved-searches with name > 100 chars returns 422."""
payload = {
"name": "x" * 101,
"filters": {"status": "completed"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 422
client = next(_make_client(int_engine, _OWNER))
resp = client.get("/api/saved-searches")
assert resp.status_code == 200
assert len(resp.json()) == 0
def test_saved_search_filters_sanitized(self, client: TestClient):
"""Saved search filters are sanitized to allowed keys only."""
payload = {
"name": "Sanitized",
"filters": {
"search": "invoice",
"mime_type": "application/pdf",
"date_from": "2026-01-01",
"date_to": "2026-12-31",
"storage_provider": "dropbox",
"tags": "invoice,amazon",
"sort_by": "created_at",
"sort_order": "desc",
},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 201
data = response.json()
assert len(data["filters"]) == 8
assert data["filters"]["search"] == "invoice"
assert data["filters"]["tags"] == "invoice,amazon"
other_search = int_session.query(SavedSearch).first()
resp = client.put(f"/api/saved-searches/{other_search.id}", json={"name": "Hacked"})
assert resp.status_code == 404
def test_saved_search_with_fulltext_query(self, client: TestClient):
"""Saved search can include full-text query (q) for the search view."""
payload = {
"name": "Invoice Search",
"filters": {"q": "invoice total amount", "document_type": "Invoice"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 201
data = response.json()
assert data["filters"]["q"] == "invoice total amount"
assert data["filters"]["document_type"] == "Invoice"
def test_saved_search_content_finding_filters(self, client: TestClient):
"""Saved search accepts content-finding filter keys (language, sender, text_quality)."""
payload = {
"name": "German Invoices",
"filters": {
"q": "rechnung",
"language": "de",
"sender": "ACME GmbH",
"text_quality": "high",
"tags": "invoice",
},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 201
data = response.json()
assert data["filters"]["q"] == "rechnung"
assert data["filters"]["language"] == "de"
assert data["filters"]["sender"] == "ACME GmbH"
assert data["filters"]["text_quality"] == "high"
assert data["filters"]["tags"] == "invoice"
def test_create_saved_search_max_limit(self, client: TestClient, db_session, mocker):
"""POST /api/saved-searches returns 409 when max limit is reached."""
from app.api.saved_searches import MAX_SAVED_SEARCHES_PER_USER
user_id = "test_user"
mocker.patch("app.api.saved_searches._get_user_id", return_value=user_id)
for i in range(MAX_SAVED_SEARCHES_PER_USER):
search = SavedSearch(user_id=user_id, name=f"Search {i}", filters="""{"tags": "invoice"}""")
db_session.add(search)
db_session.commit()
payload = {
"name": "One More",
"filters": {"tags": "invoice"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 409
def test_create_saved_search_db_error(self, client: TestClient, mocker):
"""POST /api/saved-searches handles db.commit errors gracefully."""
mocker.patch("sqlalchemy.orm.Session.commit", side_effect=Exception("DB Error"))
payload = {
"name": "Fail Me",
"filters": {"tags": "invoice"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 500
def test_update_saved_search_db_error(self, client: TestClient, mocker):
"""PUT /api/saved-searches/{id} handles db.commit errors gracefully."""
# Create a search first
payload = {
"name": "Update Target",
"filters": {"tags": "invoice"},
}
response = client.post("/api/saved-searches", json=payload)
search_id = response.json()["id"]
mocker.patch("sqlalchemy.orm.Session.commit", side_effect=Exception("DB Error"))
update_payload = {"name": "New Name"}
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
assert response.status_code == 500
def test_delete_saved_search_db_error(self, client: TestClient, mocker):
"""DELETE /api/saved-searches/{id} handles db.commit errors gracefully."""
# Create a search first
payload = {
"name": "Delete Target",
"filters": {"tags": "invoice"},
}
response = client.post("/api/saved-searches", json=payload)
search_id = response.json()["id"]
mocker.patch("sqlalchemy.orm.Session.commit", side_effect=Exception("DB Error"))
response = client.delete(f"/api/saved-searches/{search_id}")
assert response.status_code == 500
def test_update_saved_search_name_conflict(self, client: TestClient):
"""PUT /api/saved-searches/{id} returns 409 when the new name conflicts with an existing search."""
# Create search 1
payload1 = {"name": "Search One", "filters": {"tags": "invoice"}}
client.post("/api/saved-searches", json=payload1)
# Create search 2
payload2 = {"name": "Search Two", "filters": {"status": "completed"}}
response2 = client.post("/api/saved-searches", json=payload2)
search2_id = response2.json()["id"]
# Try to update search 2 to have name "Search One"
update_payload = {"name": "Search One"}
response = client.put(f"/api/saved-searches/{search2_id}", json=update_payload)
assert response.status_code == 409
def test_update_saved_search_empty_filters(self, client: TestClient):
"""PUT /api/saved-searches/{id} returns 422 if filters are empty or invalid."""
payload = {"name": "Search XYZ", "filters": {"tags": "invoice"}}
response = client.post("/api/saved-searches", json=payload)
search_id = response.json()["id"]
# Empty filters
update_payload = {"filters": {}}
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
assert response.status_code == 422
# Invalid keys
update_payload = {"filters": {"invalid_key": "value"}}
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
assert response.status_code == 422
def test_update_saved_search_invalid_name(self, client: TestClient):
"""PUT /api/saved-searches/{id} returns 422 if name is invalid or too long."""
payload = {"name": "Search XYZ", "filters": {"tags": "invoice"}}
response = client.post("/api/saved-searches", json=payload)
search_id = response.json()["id"]
# Empty name
update_payload = {"name": ""}
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
assert response.status_code == 422
# Too long name
update_payload = {"name": "A" * 101}
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
assert response.status_code == 422
def test_update_saved_search_same_name(self, client: TestClient):
"""PUT /api/saved-searches/{id} with the same name does not trigger duplicate check error."""
# Create a search
payload = {"name": "Same Name", "filters": {"tags": "invoice"}}
response = client.post("/api/saved-searches", json=payload)
search_id = response.json()["id"]
# Update with the exact same name
update_payload = {"name": "Same Name"}
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
assert response.status_code == 200
assert response.json()["name"] == "Same Name"
def test_get_user_id_branches_real(self, client: TestClient):
from app.api.saved_searches import _get_user_id
# We need a mock request
class MockRequest:
session = {}
state = type("obj", (object,), {"user": None})
req = MockRequest()
assert _get_user_id(req) == "anonymous"
req.session["user"] = {"preferred_username": "pref"}
assert _get_user_id(req) == "pref"
req.session["user"] = {"email": "em@il.com"}
assert _get_user_id(req) == "em@il.com"
req.session["user"] = {"name": "named"}
assert _get_user_id(req) == "named"
req.session["user"] = {}
assert _get_user_id(req) == "anonymous"
def test_validate_filters_not_dict(self, client: TestClient):
"""POST /api/saved-searches with non-dict filters returns 422."""
payload = {
"name": "Invalid Filters",
"filters": "not a dict",
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 422
def test_create_saved_search_non_dict_filters(self, client: TestClient):
payload = {"name": "Test", "filters": []}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 422
def test_update_saved_search_non_dict_filters(self, client: TestClient):
payload = {"name": "Test", "filters": {"tags": "invoice"}}
response = client.post("/api/saved-searches", json=payload)
search_id = response.json()["id"]
update_payload = {"filters": []}
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
assert response.status_code == 422
resp = client.delete(f"/api/saved-searches/{other_search.id}")
assert resp.status_code == 404
+3 -2
View File
@@ -69,8 +69,9 @@ class TestViewsBase:
context = {"request": req}
template_response_with_version("template.html", context)
_, kwargs = mock_orig.call_args
assert kwargs["context"].get("csrf_token") == "my-csrf"
args, kwargs = mock_orig.call_args
context = kwargs.get("context", {})
assert context.get("csrf_token") == "my-csrf"
def test_kwargs_context_no_request(self):
"""Test kwargs context path when request is not in context."""
+4 -4
View File
@@ -55,8 +55,8 @@ class TestDarkModeTemplateInjection:
captured = {}
def fake_original(request, name, **kw):
captured.update(kw.get("context", {}))
def fake_original(request_obj, name, context=None, **kw):
captured.update(context or {})
with patch("app.views.base.original_template_response", side_effect=fake_original):
mock_request = MagicMock()
@@ -73,8 +73,8 @@ class TestDarkModeTemplateInjection:
captured = {}
def fake_original(request, name, **kw):
captured.update(kw.get("context", {}))
def fake_original(request_obj, name, context=None, **kw):
captured.update(context or {})
with patch("app.views.base.original_template_response", side_effect=fake_original):
mock_request = MagicMock()
+2 -2
View File
@@ -335,7 +335,7 @@ class TestFileDetailBottomPreview:
pdf.write_bytes(b"%PDF-1.4")
rec = _create_file_record(db_session, file_path=str(pdf), processed_path=str(pdf))
response = client.get(f"/files/{rec.id}/detail")
response = client.get(f"/files/{rec.id}/process")
html = response.text
assert f"/api/files/{rec.id}/download" in html
@@ -350,7 +350,7 @@ class TestFileDetailBottomPreview:
file_path=str(img),
)
response = client.get(f"/files/{rec.id}/detail")
response = client.get(f"/files/{rec.id}/process")
html = response.text
assert f"/api/files/{rec.id}/preview?version=original" in html
+1 -1
View File
@@ -514,7 +514,7 @@ class TestFileDetailView:
def test_file_detail_view_nonexistent(self, client: TestClient):
"""Test file detail view for nonexistent file."""
response = client.get("/files/99999/detail")
response = client.get("/files/99999/process")
assert response.status_code == 200 # Returns page with error message
assert b"not found" in response.content.lower()
+3 -3
View File
@@ -142,7 +142,7 @@ class TestFileDetailPage:
db_session.commit()
# Test file detail page
response = client.get(f"/files/{file_record.id}/detail")
response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200
content = response.text
assert "test.pdf" in content
@@ -150,7 +150,7 @@ class TestFileDetailPage:
def test_file_detail_page_with_missing_file(self, client: TestClient, db_session):
"""Test file detail page with non-existent file"""
# Try to access non-existent file
response = client.get("/files/99999/detail")
response = client.get("/files/99999/process")
assert response.status_code == 200
content = response.text
assert "not found" in content.lower()
@@ -232,7 +232,7 @@ class TestFileDetailPage:
db_session.commit()
# Test file detail page
response = client.get(f"/files/{file_record.id}/detail")
response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200
content = response.text
# Should show metadata
+5 -18
View File
@@ -502,7 +502,6 @@ class TestPullAllInboxes:
class TestPullInbox:
"""Tests for pull_inbox function."""
@patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False)
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
@patch("app.tasks.imap_tasks.load_processed_emails")
@patch("app.tasks.imap_tasks.save_processed_emails")
@@ -532,7 +531,6 @@ class TestPullInbox:
mock_mail.close.assert_called_once()
mock_mail.logout.assert_called_once()
@patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False)
@patch("app.tasks.imap_tasks.imaplib.IMAP4")
@patch("app.tasks.imap_tasks.load_processed_emails")
def test_non_ssl_connection(self, mock_load, mock_imap_class):
@@ -611,7 +609,6 @@ class TestPullInbox:
# Should select INBOX as fallback
assert any(call_args[0][0] == "INBOX" for call_args in mock_mail.select.call_args_list)
@patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False)
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
@patch("app.tasks.imap_tasks.load_processed_emails")
def test_search_failure_handling(self, mock_load, mock_imap_class):
@@ -638,7 +635,6 @@ class TestPullInbox:
mock_mail.close.assert_called_once()
mock_mail.logout.assert_called_once()
@patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False)
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
@patch("app.tasks.imap_tasks.load_processed_emails")
@@ -681,15 +677,12 @@ class TestPullInbox:
mock_mail.store.assert_called_with(b"1", "-FLAGS", "\\Seen")
mock_save.assert_called()
@patch("app.tasks.imap_tasks.is_private_ip", return_value=False)
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
@patch("app.tasks.imap_tasks.load_processed_emails")
@patch("app.tasks.imap_tasks.save_processed_emails")
@patch("app.tasks.imap_tasks.settings")
def test_delete_after_process(
self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch, _mock_private_ip
):
def test_delete_after_process(self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch):
"""Test deleting messages after processing."""
mock_settings.workdir = "/tmp"
mock_settings.imap_readonly_mode = False
@@ -927,10 +920,9 @@ class TestPullInbox:
# Should not process the message
mock_mail.store.assert_not_called()
@patch("app.tasks.imap_tasks.is_private_ip", return_value=False)
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
@patch("app.tasks.imap_tasks.load_processed_emails")
def test_handles_fetch_failure(self, mock_load, mock_imap_class, _mock_private_ip):
def test_handles_fetch_failure(self, mock_load, mock_imap_class):
"""Test handling of message fetch failure."""
mock_load.return_value = {}
mock_mail = MagicMock()
@@ -1030,15 +1022,12 @@ class TestPullInbox:
# Processed emails cache should still be updated
mock_save.assert_called()
@patch("app.tasks.imap_tasks.is_private_ip", return_value=False)
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
@patch("app.tasks.imap_tasks.load_processed_emails")
@patch("app.tasks.imap_tasks.save_processed_emails")
@patch("app.tasks.imap_tasks.settings")
def test_readonly_mode_skips_delete(
self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch, _mock_private_ip
):
def test_readonly_mode_skips_delete(self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch):
"""Test that readonly mode skips deletion even when delete_after_process is True."""
mock_settings.workdir = "/tmp"
mock_settings.imap_readonly_mode = True
@@ -1392,11 +1381,10 @@ class TestAcquireReleaseLockEdgeCases:
class TestPullInboxEdgeCases:
"""Test edge cases for pull_inbox function."""
@patch("app.tasks.imap_tasks.is_private_ip", return_value=False)
@patch("app.tasks.imap_tasks.load_processed_emails")
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
@patch("app.tasks.imap_tasks.settings")
def test_pull_inbox_search_failed_status(self, mock_settings, mock_imap_class, mock_load, _mock_private_ip):
def test_pull_inbox_search_failed_status(self, mock_settings, mock_imap_class, mock_load):
"""Test pull_inbox when search returns non-OK status."""
mock_settings.workdir = "/tmp"
mock_load.return_value = {}
@@ -1443,11 +1431,10 @@ class TestPullInboxEdgeCases:
# Should skip processing since no Message-ID
mock_fetch.assert_not_called()
@patch("app.tasks.imap_tasks.is_private_ip", return_value=False)
@patch("app.tasks.imap_tasks.load_processed_emails")
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
@patch("app.tasks.imap_tasks.settings")
def test_pull_inbox_fetch_failed_status(self, mock_settings, mock_imap_class, mock_load, _mock_private_ip):
def test_pull_inbox_fetch_failed_status(self, mock_settings, mock_imap_class, mock_load):
"""Test pull_inbox when fetch returns non-OK status."""
mock_settings.workdir = "/tmp"
mock_load.return_value = {}
-52
View File
@@ -145,58 +145,6 @@ class TestLifespanEvents:
# load_settings_from_db must also have been called
mock_load_settings.assert_called_once()
@pytest.mark.asyncio
async def test_lifespan_shutdown_logging_exception_is_silenced(self):
"""Exceptions raised by logging.info during shutdown are silently ignored."""
def _raise_on_shutdown(msg, *args, **kwargs):
if "shutting down" in str(msg):
raise OSError("stream closed")
with (
patch("app.database.init_db"),
patch("app.database.SessionLocal") as mock_session_cls,
patch("app.utils.config_loader.load_settings_from_db"),
patch("app.utils.config_validator.dump_all_settings"),
patch("app.utils.config_validator.check_all_configs", return_value={"email": [], "storage": {}}),
patch("app.utils.notification.init_apprise"),
patch("app.utils.notification.notify_startup"),
patch("app.utils.notification.notify_shutdown"),
patch("app.main.init_sentry"),
patch("app.main.logging.info", side_effect=_raise_on_shutdown),
):
mock_db = MagicMock()
mock_session_cls.return_value = mock_db
from app.main import app, lifespan
# Should complete without raising despite the logging error
async with lifespan(app):
pass
@pytest.mark.asyncio
async def test_lifespan_shutdown_notify_exception_is_silenced(self):
"""Exceptions raised by notify_shutdown during shutdown are silently ignored."""
with (
patch("app.database.init_db"),
patch("app.database.SessionLocal") as mock_session_cls,
patch("app.utils.config_loader.load_settings_from_db"),
patch("app.utils.config_validator.dump_all_settings"),
patch("app.utils.config_validator.check_all_configs", return_value={"email": [], "storage": {}}),
patch("app.utils.notification.init_apprise"),
patch("app.utils.notification.notify_startup"),
patch("app.main.notify_shutdown", side_effect=OSError("stream closed")),
patch("app.main.init_sentry"),
):
mock_db = MagicMock()
mock_session_cls.return_value = mock_db
from app.main import app, lifespan
# Should complete without raising despite the notify_shutdown error
async with lifespan(app):
pass
@pytest.mark.unit
class TestExceptionHandlers:
+2 -2
View File
@@ -345,7 +345,7 @@ class TestLoginPageSocialProviders:
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
context = call_args.kwargs["context"]
context = call_args.kwargs.get("context", {})
assert context["social_providers"] == mock_providers
@pytest.mark.asyncio
@@ -371,7 +371,7 @@ class TestLoginPageSocialProviders:
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
context = call_args.kwargs["context"]
context = call_args.kwargs.get("context", {})
assert context["social_providers"] == {}
+5
View File
@@ -769,6 +769,11 @@ class TestUploadRclone:
cmd = mock_run.call_args[0][0]
assert cmd[0] == "rclone"
assert cmd[1] == "copyto"
# SECURITY: Verify `--` end-of-options separator is present and precedes
# the file path and destination to prevent option/argument injection.
assert "--" in cmd
fp_index = next(i for i, v in enumerate(cmd) if v == fp)
assert cmd.index("--") < fp_index
def test_raises_on_rclone_nonzero_exit(self, tmp_path):
fp = str(tmp_path / "doc.pdf")
@@ -1,52 +0,0 @@
from unittest.mock import MagicMock, patch
import pytest
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
@pytest.fixture
def mock_settings(tmp_path):
with patch("app.tasks.upload_to_nextcloud.settings") as mock:
mock.nextcloud_upload_url = "http://nextcloud.local/"
mock.nextcloud_username = "testuser"
mock.nextcloud_password = "testpassword"
mock.nextcloud_folder = "uploads"
mock.workdir = str(tmp_path)
mock.http_request_timeout = 30
yield mock
@pytest.fixture
def mock_requests():
with patch("app.tasks.upload_to_nextcloud.requests") as mock:
# Mock PROPFIND to always return false (file doesn't exist)
mock.request.return_value = MagicMock(text="<response></response>")
# Mock PUT to return success
put_response = MagicMock()
put_response.status_code = 201
mock.put.return_value = put_response
yield mock
def test_upload_to_nextcloud_url_construction(tmp_path, mock_settings, mock_requests):
file_path = str(tmp_path / "test_file.txt")
# Create dummy file
with open(file_path, "w") as f:
f.write("test content")
# Call the task directly
with patch("celery.app.task.Task.request", new_callable=MagicMock) as mock_req:
mock_req.id = "test-task-123"
result = upload_to_nextcloud(file_path)
assert result["status"] == "Completed"
assert result["nextcloud_path"] == "uploads/test_file.txt"
# Verify requests.put was called with the correct URL
mock_requests.put.assert_called_once()
args, kwargs = mock_requests.put.call_args
url = args[0]
assert url == "http://nextcloud.local/uploads/test_file.txt"
-68
View File
@@ -879,71 +879,3 @@ class TestURLUploadCoverageGaps:
# Generic exception (not HTTPException/OSError/RequestException) is caught and returns 500
assert response.status_code == 500
assert "Unexpected error" in response.json()["detail"]
@pytest.mark.asyncio
async def test_verify_redirect_allows_safe_url(self):
"""Test verify_redirect allows safe redirects (lines 115, 118, 120-121)"""
import httpx
from app.api.url_upload import verify_redirect
req = httpx.Request("GET", "http://example.com")
resp = httpx.Response(301, headers={"Location": "https://google.com"}, request=req)
# Should not raise any exception
await verify_redirect(resp)
@pytest.mark.asyncio
@patch("app.api.url_upload.validate_url_safety")
async def test_verify_redirect_blocks_unsafe_url(self, mock_validate):
"""Test verify_redirect blocks unsafe redirects (lines 122-125)"""
import httpx
from fastapi import HTTPException
from app.api.url_upload import verify_redirect
mock_validate.side_effect = HTTPException(status_code=400, detail="Unsafe URL")
req = httpx.Request("GET", "http://example.com")
resp = httpx.Response(301, headers={"Location": "http://127.0.0.1"}, request=req)
with pytest.raises(httpx.RequestError) as exc_info:
await verify_redirect(resp)
assert "Redirect to unsafe URL blocked" in str(exc_info.value)
@patch("app.api.url_upload.validate_url_safety")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_validate_redirect_hook_blocks_unsafe_url(self, mock_stream, mock_validate, client):
"""Test that the local validate_redirect hook successfully aborts the request when redirect is unsafe"""
import httpx
# The local validate_redirect hook intercepts 301/302 and throws an httpx.RequestError
# Here we mock the behavior of that hook executing during the stream context
def side_effect(*args, **kwargs):
# Raise a simulated RequestError caused by validate_redirect
raise httpx.RequestError(
"Unsafe redirect target: Access to private IP addresses is not allowed",
request=httpx.Request("GET", "http://example.com"),
)
mock_stream.side_effect = side_effect
response = client.post("/api/process-url", json={"url": "http://example.com"})
# Our exception handler in process_url converts RequestError to a 500 HTTPException
assert response.status_code == 500
assert "Unsafe redirect target" in response.json()["detail"]
@pytest.mark.asyncio
async def test_verify_redirect_ignores_non_redirects(self):
"""Test verify_redirect ignores 200 OK responses"""
import httpx
from app.api.url_upload import verify_redirect
req = httpx.Request("GET", "http://example.com")
resp = httpx.Response(200, request=req)
# Should not raise any exception and should ignore missing Location header
await verify_redirect(resp)
-18
View File
@@ -129,15 +129,6 @@ class TestSetupWizardUndoSkip:
class TestDropboxSaveSettingsDbPersist:
"""Unit tests for save_dropbox_settings DB persistence."""
@pytest.fixture(autouse=True)
def _admin_override(self):
from app.api.dropbox import _require_admin
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
yield
fastapi_app.dependency_overrides.pop(_require_admin, None)
@patch("app.api.dropbox.settings")
@patch("app.api.dropbox.notify_settings_updated")
@patch("app.api.dropbox.save_setting_to_db")
@@ -277,15 +268,6 @@ class TestGoogleDriveUpdateSettingsDbPersist:
class TestOneDriveSaveSettingsDbPersist:
"""Unit tests for save_onedrive_settings DB persistence."""
@pytest.fixture(autouse=True)
def _admin_override(self):
from app.api.onedrive import _require_admin
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
yield
fastapi_app.dependency_overrides.pop(_require_admin, None)
@patch("app.api.onedrive.settings")
@patch("app.api.onedrive.notify_settings_updated")
@patch("app.api.onedrive.save_setting_to_db")
Vendored Submodule
+1
Submodule vendor/embed-pdf-viewer added at aa45d6ef07