Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16de54f66b |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -200,3 +200,6 @@ cython_debug/
|
||||
# Build metadata files - generated at build time
|
||||
GIT_SHA
|
||||
RUNTIME_INFO
|
||||
|
||||
# Frontend build tooling
|
||||
frontend/node_modules/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "vendor/embed-pdf-viewer"]
|
||||
path = vendor/embed-pdf-viewer
|
||||
url = https://github.com/embedpdf/embed-pdf-viewer.git
|
||||
+8
-4
@@ -1,4 +1,8 @@
|
||||
## 2025-02-14 - SSRF vulnerability in webhook outgoing requests
|
||||
**Vulnerability:** Found an SSRF vulnerability where outgoing webhook requests could hit private IPs or metadata endpoints (e.g. 169.254.169.254).
|
||||
**Learning:** This existed because the `url` parameter provided for webhooks (`app/utils/webhook.py` and `app/utils/user_notification.py`) was not being checked before being passed to `requests.post()` or `httpx.post()`.
|
||||
**Prevention:** Make sure to always validate URL scheme and hostname with `is_private_ip()` and block known cloud metadata endpoints before doing outgoing network requests based on dynamic values.
|
||||
## 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.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2026-04-07T09:34:53Z
|
||||
2026-03-22T18:47:07Z
|
||||
|
||||
-409
@@ -13,415 +13,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]
|
||||
([`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]
|
||||
([`45d3ac8`](https://github.com/christianlouis/DocuElevate/commit/45d3ac8cf07d39d49930dd6866f76e6015067b08))
|
||||
|
||||
### Testing
|
||||
|
||||
- Add assertions for task enqueuing parameters
|
||||
([`eeae47d`](https://github.com/christianlouis/DocuElevate/commit/eeae47ddec01339421e503ba484157e798750b8a))
|
||||
|
||||
|
||||
## 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.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
|
||||
|
||||
+9
-11
@@ -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
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
+6
-6
@@ -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.1
|
||||
Build Date: 2026-03-22T18:47:07Z
|
||||
Git Commit: 76c0e91500963fac4e8d4a43123340a7cc64731f
|
||||
Git Short SHA: 76c0e91
|
||||
Git Branch: main
|
||||
Commit Date: 2026-04-07T11:34:28+02:00
|
||||
Build Timestamp: 2026-04-07T09:34:53Z
|
||||
Commit Date: 2026-03-22T19:46:48+01:00
|
||||
Build Timestamp: 2026-03-22T18:47:07Z
|
||||
==============================
|
||||
|
||||
+1
-1
@@ -260,7 +260,7 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)) -> dic
|
||||
@require_login
|
||||
async def billing_success(request: Request) -> Any:
|
||||
"""Show a success page after a completed Stripe Checkout."""
|
||||
return _templates.TemplateResponse(request, "billing_success.html")
|
||||
return _templates.TemplateResponse("billing_success.html", {"request": request})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+2
-13
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-18
@@ -515,12 +515,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 +543,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"}
|
||||
|
||||
@@ -101,9 +101,9 @@ async def signup_page(request: Request) -> Any:
|
||||
if not settings.allow_local_signup:
|
||||
return RedirectResponse(url="/login?error=Registration+is+not+enabled", status_code=302)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"signup.html",
|
||||
context={
|
||||
{
|
||||
"request": request,
|
||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||
"app_version": settings.version,
|
||||
},
|
||||
@@ -113,16 +113,16 @@ async def signup_page(request: Request) -> Any:
|
||||
@router.get("/verify-email-sent", include_in_schema=False)
|
||||
async def verify_email_sent_page(request: Request) -> Any:
|
||||
"""Render the verify-email-sent confirmation page."""
|
||||
return templates.TemplateResponse(request, "verify_email_sent.html")
|
||||
return templates.TemplateResponse("verify_email_sent.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/forgot-username", include_in_schema=False)
|
||||
async def forgot_username_page(request: Request) -> Any:
|
||||
"""Render the forgot-username page where users can request a username reminder email."""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"forgot_username.html",
|
||||
context={
|
||||
{
|
||||
"request": request,
|
||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||
"app_version": settings.version,
|
||||
},
|
||||
@@ -133,9 +133,9 @@ async def forgot_username_page(request: Request) -> Any:
|
||||
async def forgot_password_page(request: Request) -> Any:
|
||||
"""Render the forgot-password page where users can request a reset email."""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"forgot_password.html",
|
||||
context={
|
||||
{
|
||||
"request": request,
|
||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||
"app_version": settings.version,
|
||||
},
|
||||
@@ -147,9 +147,9 @@ async def reset_password_page(request: Request) -> Any:
|
||||
"""Render the password reset form page."""
|
||||
token = request.query_params.get("token", "")
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"password_reset_form.html",
|
||||
context={
|
||||
{
|
||||
"request": request,
|
||||
"token": token,
|
||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||
"app_version": settings.version,
|
||||
|
||||
+2
-13
@@ -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).
|
||||
|
||||
@@ -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(
|
||||
@@ -184,7 +165,6 @@ async def process_url(
|
||||
headers={
|
||||
"User-Agent": "DocuElevate/1.0", # Identify ourselves
|
||||
},
|
||||
event_hooks={"response": [verify_redirect]},
|
||||
) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
+2
-2
@@ -536,9 +536,9 @@ async def login(request: Request):
|
||||
return RedirectResponse(url="/oauth-login", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
context={
|
||||
{
|
||||
"request": request,
|
||||
"error": error,
|
||||
"message": message,
|
||||
"show_oauth": show_oauth,
|
||||
|
||||
+7
-12
@@ -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(
|
||||
@@ -430,13 +424,15 @@ async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
# For frontend routes, return appropriate HTML templates
|
||||
# Handle 404 errors with a custom template
|
||||
if exc.status_code == 404:
|
||||
return _error_templates.TemplateResponse(request, "404.html", status_code=status.HTTP_404_NOT_FOUND)
|
||||
return _error_templates.TemplateResponse(
|
||||
"404.html", {"request": request}, status_code=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# For other HTTP errors, we could create specific templates or use a generic one
|
||||
# For now, return a simple error page
|
||||
return _error_templates.TemplateResponse(
|
||||
request,
|
||||
"404.html", # Reuse 404 template for other errors, or create a generic error template
|
||||
{"request": request},
|
||||
status_code=exc.status_code,
|
||||
)
|
||||
|
||||
@@ -456,9 +452,8 @@ async def custom_500_handler(request: Request, exc: Exception):
|
||||
|
||||
# Serve the 500 template for non-API routes
|
||||
return _error_templates.TemplateResponse(
|
||||
request,
|
||||
"500.html",
|
||||
context={"exc": exc},
|
||||
{"request": request, "exc": exc},
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,19 +7,8 @@ def hash_file(filepath: str | Path, chunk_size: int = 65536) -> str:
|
||||
Returns the SHA-256 hash of the file at 'filepath'.
|
||||
Reads the file in chunks to handle large files efficiently.
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
filepath_obj = Path(filepath).resolve()
|
||||
workdir_obj = Path(settings.workdir).resolve()
|
||||
|
||||
# Security check: Ensure the resolved path is strictly within the allowed workdir
|
||||
try:
|
||||
filepath_obj.relative_to(workdir_obj)
|
||||
except ValueError:
|
||||
raise FileNotFoundError(f"Access denied: path traversal attempt or file outside workdir '{filepath}'")
|
||||
|
||||
sha256 = hashlib.sha256()
|
||||
with open(filepath_obj, "rb") as f:
|
||||
with open(filepath, "rb") as f:
|
||||
while True:
|
||||
data = f.read(chunk_size)
|
||||
if not data:
|
||||
|
||||
+5
-31
@@ -1,7 +1,6 @@
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,33 +27,8 @@ def is_private_ip(hostname: str) -> bool:
|
||||
return True
|
||||
return False
|
||||
except (socket.gaierror, socket.error):
|
||||
# Cannot resolve.
|
||||
# Fail securely: block unresolved domains to prevent DNS rebinding
|
||||
# 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))
|
||||
# Cannot resolve - allow for testing/development
|
||||
# In production, DNS should work properly
|
||||
# Log this for debugging
|
||||
logger.warning(f"Could not resolve hostname: {hostname}")
|
||||
return False # Changed from True to False to allow external domains in tests
|
||||
|
||||
@@ -12,7 +12,6 @@ import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -129,31 +128,6 @@ def _send_webhook_notification(target_config: dict[str, Any], event_type: str, t
|
||||
logger.warning("Webhook notification target missing url")
|
||||
return False
|
||||
|
||||
from app.utils.network import is_private_ip
|
||||
|
||||
parsed_url = urlparse(url)
|
||||
if parsed_url.scheme not in ("http", "https"):
|
||||
logger.warning("Webhook notification to %s blocked: Invalid scheme %s", url, parsed_url.scheme)
|
||||
return False
|
||||
|
||||
hostname = parsed_url.hostname
|
||||
if not hostname:
|
||||
logger.warning("Webhook notification to %s blocked: No hostname", url)
|
||||
return False
|
||||
|
||||
if is_private_ip(hostname):
|
||||
logger.warning("Webhook notification to %s blocked: Private IP", url)
|
||||
return False
|
||||
|
||||
metadata_endpoints = [
|
||||
"169.254.169.254", # AWS, Azure, GCP metadata
|
||||
"metadata.google.internal", # GCP
|
||||
"169.254.169.253", # AWS link-local
|
||||
]
|
||||
if hostname in metadata_endpoints:
|
||||
logger.warning("Webhook notification to %s blocked: Metadata endpoint", url)
|
||||
return False
|
||||
|
||||
payload = {
|
||||
"event": event_type,
|
||||
"title": title,
|
||||
|
||||
@@ -18,7 +18,6 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
@@ -68,31 +67,6 @@ def deliver_webhook(url: str, payload: dict[str, Any], secret: str | None = None
|
||||
Returns:
|
||||
``True`` when the remote server responds with a 2xx status.
|
||||
"""
|
||||
from app.utils.network import is_private_ip
|
||||
|
||||
parsed_url = urlparse(url)
|
||||
if parsed_url.scheme not in ("http", "https"):
|
||||
logger.warning("Webhook to %s blocked: Invalid scheme %s", url, parsed_url.scheme)
|
||||
return False
|
||||
|
||||
hostname = parsed_url.hostname
|
||||
if not hostname:
|
||||
logger.warning("Webhook to %s blocked: No hostname", url)
|
||||
return False
|
||||
|
||||
if is_private_ip(hostname):
|
||||
logger.warning("Webhook to %s blocked: Private IP", url)
|
||||
return False
|
||||
|
||||
metadata_endpoints = [
|
||||
"169.254.169.254", # AWS, Azure, GCP metadata
|
||||
"metadata.google.internal", # GCP
|
||||
"169.254.169.253", # AWS link-local
|
||||
]
|
||||
if hostname in metadata_endpoints:
|
||||
logger.warning("Webhook to %s blocked: Metadata endpoint", url)
|
||||
return False
|
||||
|
||||
body = json.dumps(payload, default=str, sort_keys=True)
|
||||
body_bytes = body.encode("utf-8")
|
||||
|
||||
|
||||
+5
-29
@@ -162,36 +162,12 @@ def _inject_global_context(ctx: dict) -> None:
|
||||
|
||||
|
||||
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):
|
||||
"""Wrapper for TemplateResponse to include version and CSRF token in all templates"""
|
||||
# If context dict is provided, add version to it
|
||||
if len(args) >= 2 and isinstance(args[1], dict):
|
||||
_inject_global_context(args[1])
|
||||
elif "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)
|
||||
|
||||
|
||||
|
||||
+1
-2
@@ -23,7 +23,6 @@ templates = Jinja2Templates(directory=str(_templates_dir))
|
||||
async def shared_link_view(request: Request, token: str):
|
||||
"""Render the public share landing page for a given token."""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"shared_link_view.html",
|
||||
context={"token": token},
|
||||
{"request": request, "token": token},
|
||||
)
|
||||
|
||||
+25
-4
@@ -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
|
||||
|
||||
Generated
+6
-6
@@ -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": {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -1392,7 +1392,7 @@ function integrationsDashboard() {
|
||||
body: JSON.stringify({
|
||||
integration_type: intg.integration_type,
|
||||
config: intg.config,
|
||||
credentials: creds,
|
||||
credentials: creds.credentials,
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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=="
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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__)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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 +0,0 @@
|
||||
Hello
|
||||
@@ -83,6 +83,8 @@ class TestGotenbergCoverageDocuments:
|
||||
".tif",
|
||||
".webp",
|
||||
".svg",
|
||||
".heic",
|
||||
".heif",
|
||||
}
|
||||
_html_extensions = {".html", ".htm"}
|
||||
_markdown_extensions = {".md", ".markdown"}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -998,23 +998,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 +1011,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 = {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")):
|
||||
|
||||
@@ -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
@@ -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
-3
@@ -430,8 +430,8 @@ class TestLoginFunction:
|
||||
# Verify TemplateResponse was called with correct context
|
||||
mock_templates.TemplateResponse.assert_called_once()
|
||||
call_args = mock_templates.TemplateResponse.call_args
|
||||
assert call_args[0][1] == "login.html"
|
||||
context = call_args.kwargs["context"]
|
||||
assert call_args[0][0] == "login.html"
|
||||
context = call_args[0][1]
|
||||
assert context["error"] == "Test error"
|
||||
assert context["message"] == "Test message"
|
||||
|
||||
@@ -450,7 +450,7 @@ class TestLoginFunction:
|
||||
|
||||
mock_templates.TemplateResponse.assert_called_once()
|
||||
call_args = mock_templates.TemplateResponse.call_args
|
||||
context = call_args.kwargs["context"]
|
||||
context = call_args[0][1]
|
||||
assert context["error"] is None
|
||||
assert context["message"] is None
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ class TestLoginEndpoint:
|
||||
# Verify template was rendered with OAuth enabled
|
||||
mock_templates.TemplateResponse.assert_called_once()
|
||||
call_args = mock_templates.TemplateResponse.call_args
|
||||
context = call_args.kwargs["context"]
|
||||
context = call_args[0][1]
|
||||
assert context["show_oauth"] is True
|
||||
assert context["oauth_provider_name"] == "Test SSO"
|
||||
|
||||
|
||||
@@ -520,14 +520,14 @@ class TestURLUploadAdditionalCoverage:
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
def test_is_private_ip_unresolvable_hostname(self):
|
||||
"""Cover DNS resolution failure branch blocking unresolvable domains."""
|
||||
"""Cover DNS resolution failure branch (lines 67-72)."""
|
||||
import socket as _socket
|
||||
|
||||
from app.utils.network import is_private_ip
|
||||
|
||||
with patch("socket.getaddrinfo", side_effect=_socket.gaierror("nope")):
|
||||
result = is_private_ip("nonexistent.invalid.hostname.test")
|
||||
assert result is True # Fail securely by returning True
|
||||
assert result is False
|
||||
|
||||
def test_is_private_ip_hostname_resolves_to_private(self):
|
||||
"""Cover branch where hostname resolves to a private IP (line 64-65)."""
|
||||
|
||||
@@ -69,8 +69,8 @@ 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, _ = mock_orig.call_args
|
||||
assert args[1].get("csrf_token") == "my-csrf"
|
||||
|
||||
def test_kwargs_context_no_request(self):
|
||||
"""Test kwargs context path when request is not in context."""
|
||||
|
||||
@@ -55,8 +55,8 @@ class TestDarkModeTemplateInjection:
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_original(request, name, **kw):
|
||||
captured.update(kw.get("context", {}))
|
||||
def fake_original(name, ctx, **kw):
|
||||
captured.update(ctx)
|
||||
|
||||
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(name, ctx, **kw):
|
||||
captured.update(ctx)
|
||||
|
||||
with patch("app.views.base.original_template_response", side_effect=fake_original):
|
||||
mock_request = MagicMock()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Tests for frontend build configuration and Docker build consistency.
|
||||
|
||||
Validates that the frontend build toolchain (Tailwind CSS) is correctly
|
||||
configured in package.json and that the Dockerfile installs all required
|
||||
dependencies for the build step.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Resolve the project root from the test file location
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
FRONTEND_DIR = PROJECT_ROOT / "frontend"
|
||||
DOCKERFILE_PATH = PROJECT_ROOT / "Dockerfile"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFrontendPackageJson:
|
||||
"""Validate frontend/package.json structure and scripts."""
|
||||
|
||||
def test_package_json_exists(self) -> None:
|
||||
"""package.json must exist in the frontend directory."""
|
||||
pkg_path = FRONTEND_DIR / "package.json"
|
||||
assert pkg_path.exists(), "frontend/package.json not found"
|
||||
|
||||
def test_package_json_is_valid_json(self) -> None:
|
||||
"""package.json must be parseable JSON."""
|
||||
pkg_path = FRONTEND_DIR / "package.json"
|
||||
data = json.loads(pkg_path.read_text(encoding="utf-8"))
|
||||
assert isinstance(data, dict), "package.json must be a JSON object"
|
||||
|
||||
def test_build_script_defined(self) -> None:
|
||||
"""A 'build' script must be defined in package.json."""
|
||||
pkg_path = FRONTEND_DIR / "package.json"
|
||||
data = json.loads(pkg_path.read_text(encoding="utf-8"))
|
||||
scripts = data.get("scripts", {})
|
||||
assert "build" in scripts, "Missing 'build' script in package.json"
|
||||
|
||||
def test_build_script_uses_tailwindcss(self) -> None:
|
||||
"""The build script must invoke the tailwindcss CLI."""
|
||||
pkg_path = FRONTEND_DIR / "package.json"
|
||||
data = json.loads(pkg_path.read_text(encoding="utf-8"))
|
||||
build_cmd = data["scripts"]["build"]
|
||||
assert "tailwindcss" in build_cmd, f"Build script does not reference tailwindcss: {build_cmd}"
|
||||
|
||||
def test_tailwindcss_listed_as_dependency(self) -> None:
|
||||
"""tailwindcss must be listed in dependencies or devDependencies."""
|
||||
pkg_path = FRONTEND_DIR / "package.json"
|
||||
data = json.loads(pkg_path.read_text(encoding="utf-8"))
|
||||
deps = data.get("dependencies", {})
|
||||
dev_deps = data.get("devDependencies", {})
|
||||
all_deps = {**deps, **dev_deps}
|
||||
assert "tailwindcss" in all_deps, "tailwindcss is not listed in dependencies or devDependencies"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFrontendBuildAssets:
|
||||
"""Validate that required frontend build source files exist."""
|
||||
|
||||
def test_input_css_exists(self) -> None:
|
||||
"""The Tailwind CSS input file must exist."""
|
||||
input_css = FRONTEND_DIR / "input.css"
|
||||
assert input_css.exists(), "frontend/input.css not found"
|
||||
|
||||
def test_input_css_has_tailwind_directives(self) -> None:
|
||||
"""input.css must include Tailwind CSS directives."""
|
||||
input_css = FRONTEND_DIR / "input.css"
|
||||
content = input_css.read_text(encoding="utf-8")
|
||||
assert "@tailwind base" in content, "Missing @tailwind base directive"
|
||||
assert "@tailwind components" in content, "Missing @tailwind components directive"
|
||||
assert "@tailwind utilities" in content, "Missing @tailwind utilities directive"
|
||||
|
||||
def test_tailwind_config_exists(self) -> None:
|
||||
"""tailwind.config.js must exist in the frontend directory."""
|
||||
config_path = FRONTEND_DIR / "tailwind.config.js"
|
||||
assert config_path.exists(), "frontend/tailwind.config.js not found"
|
||||
|
||||
def test_package_lock_exists(self) -> None:
|
||||
"""package-lock.json must exist for reproducible installs."""
|
||||
lock_path = FRONTEND_DIR / "package-lock.json"
|
||||
assert lock_path.exists(), "frontend/package-lock.json not found"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDockerfileFrontendBuilder:
|
||||
"""Validate the Dockerfile frontend-builder stage installs build dependencies."""
|
||||
|
||||
def test_dockerfile_exists(self) -> None:
|
||||
"""Production Dockerfile must exist at the project root."""
|
||||
assert DOCKERFILE_PATH.exists(), "Dockerfile not found at project root"
|
||||
|
||||
def test_dockerfile_has_frontend_builder_stage(self) -> None:
|
||||
"""Dockerfile must define a frontend-builder stage."""
|
||||
content = DOCKERFILE_PATH.read_text(encoding="utf-8")
|
||||
assert "AS frontend-builder" in content, "Dockerfile does not define a frontend-builder stage"
|
||||
|
||||
def test_dockerfile_npm_ci_does_not_omit_dev(self) -> None:
|
||||
"""npm ci must NOT use --omit=dev in the frontend-builder stage.
|
||||
|
||||
The tailwindcss CLI is a devDependency required at build time.
|
||||
Using --omit=dev would skip installing it, causing the build to
|
||||
fail with 'tailwindcss: not found'.
|
||||
"""
|
||||
content = DOCKERFILE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
# Extract the frontend-builder stage content
|
||||
# Look for the stage start and the next stage (or end of file)
|
||||
stage_pattern = re.compile(
|
||||
r"FROM\s+\S+\s+AS\s+frontend-builder\b(.*?)(?=FROM\s|\Z)",
|
||||
re.DOTALL,
|
||||
)
|
||||
match = stage_pattern.search(content)
|
||||
assert match is not None, "Could not find frontend-builder stage in Dockerfile"
|
||||
|
||||
stage_content = match.group(1)
|
||||
assert "--omit=dev" not in stage_content, (
|
||||
"Dockerfile frontend-builder stage uses 'npm ci --omit=dev' which "
|
||||
"excludes tailwindcss (a devDependency) needed for the build step. "
|
||||
"Use 'npm ci' instead to install all dependencies."
|
||||
)
|
||||
|
||||
def test_dockerfile_runs_npm_build(self) -> None:
|
||||
"""Dockerfile frontend-builder stage must run npm run build."""
|
||||
content = DOCKERFILE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
stage_pattern = re.compile(
|
||||
r"FROM\s+\S+\s+AS\s+frontend-builder\b(.*?)(?=FROM\s|\Z)",
|
||||
re.DOTALL,
|
||||
)
|
||||
match = stage_pattern.search(content)
|
||||
assert match is not None, "Could not find frontend-builder stage in Dockerfile"
|
||||
|
||||
stage_content = match.group(1)
|
||||
assert "npm run build" in stage_content, "Dockerfile frontend-builder stage does not run 'npm run build'"
|
||||
|
||||
def test_dockerfile_copies_compiled_css(self) -> None:
|
||||
"""Dockerfile must copy the compiled styles.css from the frontend-builder stage."""
|
||||
content = DOCKERFILE_PATH.read_text(encoding="utf-8")
|
||||
assert "COPY --from=frontend-builder" in content, (
|
||||
"Dockerfile does not copy assets from the frontend-builder stage"
|
||||
)
|
||||
assert "styles.css" in content, "Dockerfile does not reference the compiled styles.css"
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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[0][1]
|
||||
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[0][1]
|
||||
assert context["social_providers"] == {}
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -698,18 +698,6 @@ class TestURLUploadCoverageGaps:
|
||||
assert result is False
|
||||
mock_getaddrinfo.assert_called_once()
|
||||
|
||||
@patch("app.utils.network.socket.getaddrinfo")
|
||||
def test_is_private_ip_unresolvable_hostname_fails_securely(self, mock_getaddrinfo):
|
||||
"""Test that unresolvable hostnames fail securely by blocking access."""
|
||||
import socket
|
||||
|
||||
from app.utils.network import is_private_ip
|
||||
|
||||
mock_getaddrinfo.side_effect = socket.gaierror("Name or service not known")
|
||||
|
||||
result = is_private_ip("unresolvable.example.internal")
|
||||
assert result is True # Fails securely
|
||||
|
||||
@patch("socket.getaddrinfo")
|
||||
def test_is_private_ip_hostname_resolves_multiple_ips_all_public(self, mock_getaddrinfo):
|
||||
"""Test hostname with multiple public IPs returns False (covers 65->61 loop branch)"""
|
||||
@@ -879,74 +867,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)
|
||||
|
||||
@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)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_redirect_coverage():
|
||||
from app.api.url_upload import verify_redirect
|
||||
|
||||
response = MagicMock(spec=httpx.Response)
|
||||
response.status_code = 301
|
||||
response.headers = httpx.Headers({"Location": "ftp://example.com"})
|
||||
response.url = httpx.URL("http://test.com")
|
||||
response.request = MagicMock(spec=httpx.Request)
|
||||
|
||||
with pytest.raises(httpx.RequestError):
|
||||
await verify_redirect(response)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_redirect_coverage2():
|
||||
from app.api.url_upload import verify_redirect
|
||||
|
||||
response = MagicMock(spec=httpx.Response)
|
||||
response.status_code = 301
|
||||
response.headers = httpx.Headers({"Location": "http://example.com"})
|
||||
response.url = httpx.URL("http://test.com")
|
||||
response.request = MagicMock(spec=httpx.Request)
|
||||
|
||||
# Should be fine
|
||||
await verify_redirect(response)
|
||||
|
||||
@@ -227,8 +227,7 @@ class TestSendEmailNotification:
|
||||
class TestSendWebhookNotification:
|
||||
"""Tests for _send_webhook_notification()."""
|
||||
|
||||
def test_success_with_secret_header(self, mocker):
|
||||
mocker.patch("app.utils.network.is_private_ip", return_value=False)
|
||||
def test_success_with_secret_header(self):
|
||||
"""Webhook sent and X-DocuElevate-Secret header set when secret provided."""
|
||||
from app.utils.user_notification import _send_webhook_notification
|
||||
|
||||
@@ -249,8 +248,7 @@ class TestSendWebhookNotification:
|
||||
assert kwargs["headers"]["X-DocuElevate-Secret"] == "mysecret"
|
||||
assert kwargs["json"]["event"] == "document.processed"
|
||||
|
||||
def test_success_without_secret(self, mocker):
|
||||
mocker.patch("app.utils.network.is_private_ip", return_value=False)
|
||||
def test_success_without_secret(self):
|
||||
"""Webhook sent without X-DocuElevate-Secret header when no secret."""
|
||||
from app.utils.user_notification import _send_webhook_notification
|
||||
|
||||
@@ -635,33 +633,3 @@ class TestNotifyUserDocumentHelpers:
|
||||
assert "broken.pdf" in notifs[0].title
|
||||
assert "Timeout" in notifs[0].message
|
||||
assert notifs[0].event_type == "document.failed"
|
||||
|
||||
def test_webhook_coverage():
|
||||
from app.utils.user_notification import _send_webhook_notification
|
||||
assert _send_webhook_notification({"url": "http://169.254.169.254"}, "test", "test", "test") == False
|
||||
assert _send_webhook_notification({"url": "http://localhost"}, "test", "test", "test") == False
|
||||
assert _send_webhook_notification({"url": "ftp://example.com"}, "test", "test", "test") == False
|
||||
assert _send_webhook_notification({"url": "http://"}, "test", "test", "test") == False
|
||||
assert _send_webhook_notification({"url": "http://foo.bar.baz"}, "test", "test", "test") == False
|
||||
assert _send_webhook_notification({"url": ""}, "test", "test", "test") == False
|
||||
|
||||
def test_webhook_coverage2(mocker):
|
||||
from app.utils.user_notification import _send_webhook_notification
|
||||
mocker.patch("app.utils.network.is_private_ip", return_value=False)
|
||||
assert _send_webhook_notification({"url": "http://127.0.0.1"}, "test", "test", "test") == False
|
||||
|
||||
def test_webhook_coverage3(mocker):
|
||||
mocker.patch("app.utils.network.is_private_ip", return_value=False)
|
||||
from app.utils.user_notification import _send_webhook_notification
|
||||
assert _send_webhook_notification({"url": "http://127.0.0.1"}, "test", "test", "test") == False
|
||||
assert _send_webhook_notification({"url": "http://169.254.169.253"}, "test", "test", "test") == False
|
||||
assert _send_webhook_notification({"url": "http://metadata.google.internal"}, "test", "test", "test") == False
|
||||
|
||||
def test_webhook_coverage5():
|
||||
from app.utils.user_notification import _send_webhook_notification
|
||||
assert _send_webhook_notification({}, "test", "test", "test") == False
|
||||
|
||||
def test_webhook_coverage4(mocker):
|
||||
from app.utils.user_notification import _send_webhook_notification
|
||||
mocker.patch("app.utils.network.is_private_ip", return_value=True)
|
||||
assert _send_webhook_notification({"url": "http://127.0.0.1"}, "test", "test", "test") == False
|
||||
|
||||
@@ -438,7 +438,6 @@ class TestDeliverWebhookTask:
|
||||
"""Tests for the Celery webhook delivery task."""
|
||||
|
||||
def test_success_returns_status_dict(self, mocker):
|
||||
mocker.patch("app.utils.network.is_private_ip", return_value=False)
|
||||
"""Task returns a dict on successful delivery."""
|
||||
mocker.patch("app.tasks.webhook_tasks.deliver_webhook", return_value=True)
|
||||
|
||||
@@ -461,32 +460,3 @@ class TestDeliverWebhookTask:
|
||||
|
||||
with pytest.raises(RuntimeError, match="Webhook delivery.*failed"):
|
||||
deliver_webhook_task.__wrapped__("https://example.com/hook", {"event": "test"}, None)
|
||||
|
||||
def test_webhook_ssrf_coverage():
|
||||
from app.utils.webhook import deliver_webhook
|
||||
assert deliver_webhook("ftp://example.com", {"data": 1}) == False
|
||||
assert deliver_webhook("http://", {"data": 1}) == False
|
||||
assert deliver_webhook("http://localhost", {"data": 1}) == False
|
||||
assert deliver_webhook("http://169.254.169.254", {"data": 1}) == False
|
||||
assert deliver_webhook("", {"data": 1}) == False
|
||||
|
||||
def test_webhook_ssrf_coverage2(mocker):
|
||||
from app.utils.webhook import deliver_webhook
|
||||
mocker.patch("app.utils.network.is_private_ip", return_value=False)
|
||||
assert deliver_webhook("http://127.0.0.1", {"data": 1}) == False
|
||||
|
||||
def test_webhook_ssrf_coverage3(mocker):
|
||||
mocker.patch("app.utils.network.is_private_ip", return_value=False)
|
||||
from app.utils.webhook import deliver_webhook
|
||||
assert deliver_webhook("http://127.0.0.1", {"data": 1}) == False
|
||||
assert deliver_webhook("http://169.254.169.253", {"data": 1}) == False
|
||||
assert deliver_webhook("http://metadata.google.internal", {"data": 1}) == False
|
||||
|
||||
def test_webhook_ssrf_coverage5():
|
||||
from app.utils.webhook import deliver_webhook
|
||||
assert deliver_webhook("http://example.com/test", {"data": 1}) == False
|
||||
|
||||
def test_webhook_ssrf_coverage4(mocker):
|
||||
from app.utils.webhook import deliver_webhook
|
||||
mocker.patch("app.utils.network.is_private_ip", return_value=True)
|
||||
assert deliver_webhook("http://127.0.0.1", {"data": 1}) == False
|
||||
|
||||
@@ -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")
|
||||
|
||||
+1
Submodule vendor/embed-pdf-viewer added at aa45d6ef07
Reference in New Issue
Block a user