Compare commits

..

4 Commits

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

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

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-23 15:13:35 +00:00
48 changed files with 2332 additions and 612 deletions
+3
View File
@@ -200,3 +200,6 @@ cython_debug/
# Build metadata files - generated at build time
GIT_SHA
RUNTIME_INFO
# Frontend build tooling
frontend/node_modules/
+8 -8
View File
@@ -1,8 +1,8 @@
## 2024-05-24 - SSRF in WebDAV connection test
**Vulnerability:** The `_test_webdav_connection` function had a custom SSRF check that failed to resolve DNS names, allowing attackers to bypass the check by providing a domain that resolves to an internal IP (e.g., `127.0.0.1`).
**Learning:** DNS resolution is required for robust SSRF protection when validating URLs provided by users.
**Prevention:** Use a centralized `is_private_ip` function (now in `app/utils/network.py`) that resolves the hostname to its IPs and checks if any are private.
## 2026-03-22 - B310: urllib.request.urlopen replaced with httpx
**Vulnerability:** The `_test_webdav_connection` function used `urllib.request.urlopen`, which natively supports dangerous schemes like `file://` or `ftp://` and follows redirects by default, potentially allowing SSRF bypasses or Local File Inclusion.
**Learning:** `urllib.request` should be avoided for user-supplied URLs. Even when URL schemes are manually validated, `urllib`'s default redirect following behavior can bypass SSRF protections (e.g. redirecting to `127.0.0.1`).
**Prevention:** Use a modern, safer HTTP client like `httpx` with `follow_redirects=False` when testing user-provided URLs.
## 2026-03-20 - Safe Path Traversal Prevention in Low-Level Utilities
**Vulnerability:** The generic file utility `hash_file` in `app/utils/file_operations.py` accepted any file path and was vulnerable to reading arbitrary files via path traversal (e.g., `../../../etc/passwd`) or absolute paths if an attacker could control the `filepath` argument.
**Learning:** Naively checking for `".." in path` breaks legitimate relative paths used internally by the application. Blocking absolute paths entirely also breaks functionality. Input validation should occur at the API boundary, but for defense-in-depth, low-level utilities must enforce expected boundaries (e.g., the application's `workdir`).
**Prevention:** Use `pathlib.Path.resolve()` on both the target path and the allowed base directory (`settings.workdir`). Ensure the resolved target path is strictly within the allowed boundary using `filepath_obj.relative_to(workdir_obj)`, catching the `ValueError` that is raised when the path is out of bounds. This safely blocks both relative traversal attacks and arbitrary absolute paths.
## 2025-05-18 - [SSRF Bypass via DNS Resolution Failure]
**Vulnerability:** The `is_private_ip` function in `app/utils/network.py` failed open (returned `False`) when a hostname could not be resolved (`socket.gaierror`).
**Learning:** This fail-open pattern was originally added to allow external domains in tests, but in production, it created a severe SSRF risk. An attacker could bypass SSRF protections by providing a URL that fails to resolve during the security check but resolves later (DNS rebinding), or by exploiting internal routing behaviors via unresolvable addresses.
**Prevention:** Always fail securely in network authorization functions. If a domain cannot be resolved to verify its safety, the request must be blocked (`return True` / default-deny). Tests should mock DNS resolution correctly instead of compromising production security logic.
+1 -1
View File
@@ -1 +1 @@
2026-03-22T17:37:50Z
2026-03-23T14:11:22Z
+81
View File
@@ -10,6 +10,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- version list -->
## 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.1 (2026-03-22)
### Bug Fixes
- Social login providers now work from DB config without restart
([`0c7ea67`](https://github.com/christianlouis/DocuElevate/commit/0c7ea6748da554c80ef9af1b709c08aba49174e6))
## v0.172.0 (2026-03-22)
### Features
- **ui**: Migrate Tailwind CSS from v2 CDN to v3 Play CDN (interim step)
([`1d7df13`](https://github.com/christianlouis/DocuElevate/commit/1d7df13c943cc9138dc3ed514f9ab81d861bfbac))
- **ui**: Replace Tailwind CSS CDN with compiled v3 production build
([`14b3031`](https://github.com/christianlouis/DocuElevate/commit/14b3031e63e8645c4048dd73a594e9a53a919c17))
## v0.171.3 (2026-03-22)
### Bug Fixes
- **ui**: Add missing opening script tag in base.html Sentry block
([`425472c`](https://github.com/christianlouis/DocuElevate/commit/425472c839b3564c20a29b6e983fa6b9e7d6cf9c))
## v0.171.2 (2026-03-22)
### Bug Fixes
+17 -2
View File
@@ -27,7 +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: Documentation builder ──────────────────────────────────────────
# ── Stage 2: Frontend asset builder (Tailwind CSS) ──────────────────────────
FROM node:20-alpine AS frontend-builder
WORKDIR /frontend
# Install dependencies first (layer-cached unless package.json/lockfile changes)
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
# Copy source files and compile Tailwind CSS
COPY frontend/ ./
RUN npm run build
# ── Stage 3: Documentation builder ──────────────────────────────────────────
FROM python:3.14.3-slim AS docs-builder
WORKDIR /docs
@@ -43,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 3: Runtime image ───────────────────────────────────────────────────
# ── Stage 4: Runtime image ───────────────────────────────────────────────────
FROM python:3.14.3-slim
WORKDIR /app
@@ -68,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
+1 -1
View File
@@ -1 +1 @@
3e1b352
34457f9
+6 -6
View File
@@ -1,10 +1,10 @@
DocuElevate Build Information
==============================
Version: 0.171.2
Build Date: 2026-03-22T17:37:50Z
Git Commit: 3e1b35293006b76737c7d2edfc50a14b003ef266
Git Short SHA: 3e1b352
Version: 0.172.2
Build Date: 2026-03-23T14:11:22Z
Git Commit: 34457f977509ce145b7411e83982a96b0fd0e33e
Git Short SHA: 34457f9
Git Branch: main
Commit Date: 2026-03-22T18:37:25+01:00
Build Timestamp: 2026-03-22T17:37:50Z
Commit Date: 2026-03-23T15:10:59+01:00
Build Timestamp: 2026-03-23T14:11:22Z
==============================
+10 -1
View File
@@ -1,6 +1,6 @@
# Security Audit Report
**Date:** 2026-02-12
**Date:** 2026-03-23
**Status:** Bandit Security Scan Completed - All Critical/High/Medium Issues Resolved
## Executive Summary
@@ -9,6 +9,15 @@ This document tracks security vulnerabilities found in DocuElevate and their rem
## Recent Security Fixes
### Insecure API Endpoint Exposing Integration Credentials ✅ FIXED (2026-03-23)
**Severity:** HIGH
**Issue:** The endpoint `GET /api/integrations/{integration_id}/credentials` exposed integration credentials (e.g. passwords, API keys) in plaintext over the API. Although requiring login, this allowed anyone with an active user session to extract the raw credentials. The frontend used this endpoint for testing integration connections.
**Remediation:**
- Removed the `/credentials` endpoint entirely.
- Added a new `POST /api/integrations/{integration_id}/test` endpoint that securely runs connection tests server-side without returning the decrypted credentials to the client.
### CVE-2023-36464: PyPDF2/pypdf Infinite Loop Vulnerability ✅ FIXED (2026-02-12)
**Severity:** Moderate (CVSS: 5.5)
+1 -1
View File
@@ -1 +1 @@
0.171.2
0.172.2
+1 -1
View File
@@ -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("billing_success.html", {"request": request})
return _templates.TemplateResponse(request, "billing_success.html")
# ---------------------------------------------------------------------------
+56 -25
View File
@@ -470,31 +470,6 @@ def delete_integration(
logger.info("User %s deleted integration %d", owner_id, integration_id)
@router.get("/{integration_id}/credentials", summary="Retrieve decrypted credentials for an integration")
def get_integration_credentials(
integration_id: int,
request: Request,
db: DbSession,
owner_id: CurrentOwner,
) -> dict[str, Any]:
"""Return the decrypted credentials dict for a saved integration.
This endpoint is intended for internal use by background tasks that need
to authenticate with a third-party service. Treat the response as
sensitive — it contains plaintext secrets.
"""
integration = (
db.query(UserIntegration)
.filter(UserIntegration.id == integration_id, UserIntegration.owner_id == owner_id)
.first()
)
if not integration:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Integration not found")
credentials = _decode_credentials(integration.credentials)
return {"credentials": credentials or {}}
# ---------------------------------------------------------------------------
# Connection test helpers
# ---------------------------------------------------------------------------
@@ -662,6 +637,62 @@ _CONNECTION_TESTERS: dict[str, Any] = {
# ---------------------------------------------------------------------------
@router.post("/{integration_id}/test", summary="Test a saved integration connection")
def test_saved_integration_connection(
integration_id: int,
request: Request,
db: DbSession,
owner_id: CurrentOwner,
) -> dict[str, Any]:
"""Test connection for an already-saved integration."""
integration = (
db.query(UserIntegration)
.filter(UserIntegration.id == integration_id, UserIntegration.owner_id == owner_id)
.first()
)
if not integration:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Integration not found")
tester = _CONNECTION_TESTERS.get(integration.integration_type)
if tester is None:
return {
"success": False,
"message": f"Connection testing is not yet supported for '{integration.integration_type}'. "
"The integration can still be saved and will be validated on first use.",
}
try:
config = json.loads(integration.config) if integration.config else {}
except (json.JSONDecodeError, TypeError):
logger.warning(
"Invalid JSON in integration.config for integration_id=%s, type=%s",
integration.id,
integration.integration_type,
)
return {
"success": False,
"message": (
"Saved integration configuration is invalid and cannot be tested. "
"Please edit and re-save the integration, then try again."
),
}
credentials = _decode_credentials(integration.credentials) or {}
try:
return tester(config, credentials)
except Exception:
logger.exception(
"Unexpected error testing integration_id=%s, type=%s",
integration.id,
integration.integration_type,
)
return {
"success": False,
"message": "An unexpected error occurred while testing the connection. Please check your configuration.",
}
@router.post("/test", summary="Test an integration connection without saving")
def test_integration_connection(
request: Request,
+9 -9
View File
@@ -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",
{
"request": request,
context={
"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("verify_email_sent.html", {"request": request})
return templates.TemplateResponse(request, "verify_email_sent.html")
@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",
{
"request": request,
context={
"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",
{
"request": request,
context={
"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",
{
"request": request,
context={
"token": token,
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
+221 -149
View File
@@ -45,78 +45,27 @@ OAUTH_PROVIDER_NAME = "Single Sign-On"
# Social login providers that are enabled and registered
SOCIAL_PROVIDERS: dict[str, dict[str, str]] = {}
if AUTH_ENABLED and settings.authentik_client_id and settings.authentik_client_secret:
oauth.register(
name="authentik",
client_id=settings.authentik_client_id,
client_secret=settings.authentik_client_secret,
server_metadata_url=settings.authentik_config_url,
client_kwargs={"scope": "openid profile email"},
)
OAUTH_CONFIGURED = True
OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO"
# --- Social Login Providers ---------------------------------------------------
if AUTH_ENABLED and settings.social_auth_google_enabled:
# Determine which credentials to use for Google social login
_google_client_id = settings.social_auth_google_client_id
_google_client_secret = settings.social_auth_google_client_secret
if settings.social_auth_google_use_global_credentials and not (_google_client_id and _google_client_secret):
_google_client_id = settings.google_drive_client_id
_google_client_secret = settings.google_drive_client_secret
# ---------------------------------------------------------------------------
# Helpers for dynamic (re-)registration of OAuth providers
# ---------------------------------------------------------------------------
if _google_client_id and _google_client_secret:
oauth.register(
name="google",
client_id=_google_client_id,
client_secret=_google_client_secret,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"}
logger.info("Social login provider registered: Google")
else:
logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured")
if AUTH_ENABLED and settings.social_auth_microsoft_enabled:
# Determine which credentials to use for Microsoft social login
_microsoft_client_id = settings.social_auth_microsoft_client_id
_microsoft_client_secret = settings.social_auth_microsoft_client_secret
if settings.social_auth_microsoft_use_global_credentials and not (
_microsoft_client_id and _microsoft_client_secret
):
_microsoft_client_id = settings.onedrive_client_id
_microsoft_client_secret = settings.onedrive_client_secret
def _register_oauth_client(name: str, **kwargs: object) -> None:
"""Register (or re-register) an authlib OAuth client, clearing any cached instance.
if _microsoft_client_id and _microsoft_client_secret:
tenant = settings.social_auth_microsoft_tenant or "common"
oauth.register(
name="microsoft",
client_id=_microsoft_client_id,
client_secret=_microsoft_client_secret,
server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["microsoft"] = {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"}
logger.info("Social login provider registered: Microsoft (tenant=%s)", tenant)
else:
logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured")
authlib caches the constructed client object in ``oauth._clients`` after the
first ``register()`` call. Subsequent ``register()`` calls overwrite the
registry entry but the stale cached client is still returned by
``create_client()`` / ``__getattr__``. Popping the name from ``_clients``
before re-registering ensures the new credentials are picked up immediately.
if AUTH_ENABLED and settings.social_auth_apple_enabled:
if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id:
oauth.register(
name="apple",
client_id=settings.social_auth_apple_client_id,
server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration",
client_kwargs={
"scope": "openid name email",
"response_mode": "form_post",
},
)
SOCIAL_PROVIDERS["apple"] = {"name": "Apple", "icon": "fab fa-apple", "color": "gray"}
logger.info("Social login provider registered: Apple")
else:
logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
Args:
name: Provider name (e.g. ``"google"``, ``"github"``).
**kwargs: Keyword arguments forwarded verbatim to ``oauth.register()``.
"""
oauth._clients.pop(name, None)
oauth.register(name, **kwargs)
def _dropbox_userinfo_compliance_fix(client, user_cls, token, data):
@@ -145,92 +94,215 @@ def _dropbox_userinfo_compliance_fix(client, user_cls, token, data):
return data
if AUTH_ENABLED and settings.social_auth_dropbox_enabled:
# Determine which credentials to use for Dropbox social login
_dropbox_client_id = settings.social_auth_dropbox_client_id
_dropbox_client_secret = settings.social_auth_dropbox_client_secret
if settings.social_auth_dropbox_use_global_credentials and not (_dropbox_client_id and _dropbox_client_secret):
_dropbox_client_id = settings.dropbox_app_key
_dropbox_client_secret = settings.dropbox_app_secret
def _setup_social_providers() -> None:
"""Register all configured OAuth / social-login providers from current settings.
if _dropbox_client_id and _dropbox_client_secret:
oauth.register(
name="dropbox",
client_id=_dropbox_client_id,
client_secret=_dropbox_client_secret,
authorize_url="https://www.dropbox.com/oauth2/authorize",
access_token_url="https://api.dropboxapi.com/oauth2/token",
userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account",
userinfo_compliance_fix=_dropbox_userinfo_compliance_fix,
client_kwargs={
"token_endpoint_auth_method": "client_secret_post",
"token_access_type": "offline",
},
)
SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"}
logger.info("Social login provider registered: Dropbox")
else:
logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured")
This function is **idempotent**: it clears ``SOCIAL_PROVIDERS``,
``OAUTH_CONFIGURED``, and ``OAUTH_PROVIDER_NAME`` before rebuilding them,
and calls :func:`_register_oauth_client` (which also clears the authlib
client cache) so that credential changes in the database are reflected
without an application restart.
if AUTH_ENABLED and settings.social_auth_github_enabled:
if settings.social_auth_github_client_id and settings.social_auth_github_client_secret:
oauth.register(
name="github",
client_id=settings.social_auth_github_client_id,
client_secret=settings.social_auth_github_client_secret,
authorize_url="https://github.com/login/oauth/authorize",
access_token_url="https://github.com/login/oauth/access_token",
userinfo_endpoint="https://api.github.com/user",
client_kwargs={"scope": "read:user user:email"},
)
SOCIAL_PROVIDERS["github"] = {"name": "GitHub", "icon": "fab fa-github", "color": "gray"}
logger.info("Social login provider registered: GitHub")
else:
logger.warning("SOCIAL_AUTH_GITHUB_ENABLED=true but client ID/secret not configured")
Can safely be called multiple times, e.g. after a settings reload.
"""
global OAUTH_CONFIGURED, OAUTH_PROVIDER_NAME
if AUTH_ENABLED and settings.social_auth_keycloak_enabled:
_kc_server = settings.social_auth_keycloak_server_url
_kc_realm = settings.social_auth_keycloak_realm
if (
settings.social_auth_keycloak_client_id
and settings.social_auth_keycloak_client_secret
and _kc_server
and _kc_realm
):
_kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}"
oauth.register(
name="keycloak",
client_id=settings.social_auth_keycloak_client_id,
client_secret=settings.social_auth_keycloak_client_secret,
server_metadata_url=f"{_kc_base}/.well-known/openid-configuration",
SOCIAL_PROVIDERS.clear()
OAUTH_CONFIGURED = False
OAUTH_PROVIDER_NAME = "Single Sign-On"
if not AUTH_ENABLED:
return
# --- Authentik / OIDC ---
if settings.authentik_client_id and settings.authentik_client_secret:
_register_oauth_client(
"authentik",
client_id=settings.authentik_client_id,
client_secret=settings.authentik_client_secret,
server_metadata_url=settings.authentik_config_url,
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["keycloak"] = {"name": "Keycloak", "icon": "fas fa-key", "color": "gray"}
logger.info("Social login provider registered: Keycloak (realm=%s)", _kc_realm)
else:
logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured")
OAUTH_CONFIGURED = True
OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO"
if AUTH_ENABLED and settings.social_auth_generic_oauth2_enabled:
if (
settings.social_auth_generic_oauth2_client_id
and settings.social_auth_generic_oauth2_client_secret
and settings.social_auth_generic_oauth2_authorize_url
and settings.social_auth_generic_oauth2_token_url
):
oauth.register(
name="generic_oauth2",
client_id=settings.social_auth_generic_oauth2_client_id,
client_secret=settings.social_auth_generic_oauth2_client_secret,
authorize_url=settings.social_auth_generic_oauth2_authorize_url,
access_token_url=settings.social_auth_generic_oauth2_token_url,
userinfo_endpoint=settings.social_auth_generic_oauth2_userinfo_url,
client_kwargs={"scope": settings.social_auth_generic_oauth2_scope},
)
_generic_name = settings.social_auth_generic_oauth2_name or "OAuth2"
SOCIAL_PROVIDERS["generic_oauth2"] = {"name": _generic_name, "icon": "fas fa-sign-in-alt", "color": "indigo"}
logger.info("Social login provider registered: Generic OAuth2 (%s)", _generic_name)
else:
logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured")
# --- Social Login Providers ---
# Google
if settings.social_auth_google_enabled:
_google_client_id = settings.social_auth_google_client_id
_google_client_secret = settings.social_auth_google_client_secret
if settings.social_auth_google_use_global_credentials and not (_google_client_id and _google_client_secret):
_google_client_id = settings.google_drive_client_id
_google_client_secret = settings.google_drive_client_secret
if _google_client_id and _google_client_secret:
_register_oauth_client(
"google",
client_id=_google_client_id,
client_secret=_google_client_secret,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"}
logger.info("Social login provider registered: Google")
else:
logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured")
# Microsoft
if settings.social_auth_microsoft_enabled:
_microsoft_client_id = settings.social_auth_microsoft_client_id
_microsoft_client_secret = settings.social_auth_microsoft_client_secret
if settings.social_auth_microsoft_use_global_credentials and not (
_microsoft_client_id and _microsoft_client_secret
):
_microsoft_client_id = settings.onedrive_client_id
_microsoft_client_secret = settings.onedrive_client_secret
if _microsoft_client_id and _microsoft_client_secret:
tenant = settings.social_auth_microsoft_tenant or "common"
_register_oauth_client(
"microsoft",
client_id=_microsoft_client_id,
client_secret=_microsoft_client_secret,
server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["microsoft"] = {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"}
logger.info("Social login provider registered: Microsoft (tenant=%s)", tenant)
else:
logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured")
# Apple
if settings.social_auth_apple_enabled:
if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id:
_register_oauth_client(
"apple",
client_id=settings.social_auth_apple_client_id,
server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration",
client_kwargs={
"scope": "openid name email",
"response_mode": "form_post",
},
)
SOCIAL_PROVIDERS["apple"] = {"name": "Apple", "icon": "fab fa-apple", "color": "gray"}
logger.info("Social login provider registered: Apple")
else:
logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
# Dropbox
if settings.social_auth_dropbox_enabled:
_dropbox_client_id = settings.social_auth_dropbox_client_id
_dropbox_client_secret = settings.social_auth_dropbox_client_secret
if settings.social_auth_dropbox_use_global_credentials and not (_dropbox_client_id and _dropbox_client_secret):
_dropbox_client_id = settings.dropbox_app_key
_dropbox_client_secret = settings.dropbox_app_secret
if _dropbox_client_id and _dropbox_client_secret:
_register_oauth_client(
"dropbox",
client_id=_dropbox_client_id,
client_secret=_dropbox_client_secret,
authorize_url="https://www.dropbox.com/oauth2/authorize",
access_token_url="https://api.dropboxapi.com/oauth2/token",
userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account",
userinfo_compliance_fix=_dropbox_userinfo_compliance_fix,
client_kwargs={
"token_endpoint_auth_method": "client_secret_post",
"token_access_type": "offline",
},
)
SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"}
logger.info("Social login provider registered: Dropbox")
else:
logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured")
# GitHub
if settings.social_auth_github_enabled:
if settings.social_auth_github_client_id and settings.social_auth_github_client_secret:
_register_oauth_client(
"github",
client_id=settings.social_auth_github_client_id,
client_secret=settings.social_auth_github_client_secret,
authorize_url="https://github.com/login/oauth/authorize",
access_token_url="https://github.com/login/oauth/access_token",
userinfo_endpoint="https://api.github.com/user",
client_kwargs={"scope": "read:user user:email"},
)
SOCIAL_PROVIDERS["github"] = {"name": "GitHub", "icon": "fab fa-github", "color": "gray"}
logger.info("Social login provider registered: GitHub")
else:
logger.warning("SOCIAL_AUTH_GITHUB_ENABLED=true but client ID/secret not configured")
# Keycloak
if settings.social_auth_keycloak_enabled:
_kc_server = settings.social_auth_keycloak_server_url
_kc_realm = settings.social_auth_keycloak_realm
if (
settings.social_auth_keycloak_client_id
and settings.social_auth_keycloak_client_secret
and _kc_server
and _kc_realm
):
_kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}"
_register_oauth_client(
"keycloak",
client_id=settings.social_auth_keycloak_client_id,
client_secret=settings.social_auth_keycloak_client_secret,
server_metadata_url=f"{_kc_base}/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["keycloak"] = {"name": "Keycloak", "icon": "fas fa-key", "color": "gray"}
logger.info("Social login provider registered: Keycloak (realm=%s)", _kc_realm)
else:
logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured")
# Generic OAuth2
if settings.social_auth_generic_oauth2_enabled:
if (
settings.social_auth_generic_oauth2_client_id
and settings.social_auth_generic_oauth2_client_secret
and settings.social_auth_generic_oauth2_authorize_url
and settings.social_auth_generic_oauth2_token_url
):
_register_oauth_client(
"generic_oauth2",
client_id=settings.social_auth_generic_oauth2_client_id,
client_secret=settings.social_auth_generic_oauth2_client_secret,
authorize_url=settings.social_auth_generic_oauth2_authorize_url,
access_token_url=settings.social_auth_generic_oauth2_token_url,
userinfo_endpoint=settings.social_auth_generic_oauth2_userinfo_url,
client_kwargs={"scope": settings.social_auth_generic_oauth2_scope},
)
_generic_name = settings.social_auth_generic_oauth2_name or "OAuth2"
SOCIAL_PROVIDERS["generic_oauth2"] = {
"name": _generic_name,
"icon": "fas fa-sign-in-alt",
"color": "indigo",
}
logger.info("Social login provider registered: Generic OAuth2")
else:
logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured")
def refresh_social_providers() -> None:
"""Re-register all OAuth providers from the *current* settings object.
Call this after loading or reloading settings from the database so that
providers configured (or updated) through the admin UI take effect
immediately — **no application restart required**.
This function is safe to call multiple times and is idempotent.
"""
logger.info("Refreshing social login provider registrations from current settings")
_setup_social_providers()
# Perform the initial registration from environment / default settings at
# import time. The lifespan hook and settings_sync will call
# refresh_social_providers() again after DB settings are loaded so that
# any providers configured only in the database are also active.
_setup_social_providers()
router = APIRouter()
@@ -464,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",
{
"request": request,
context={
"error": error,
"message": message,
"show_oauth": show_oauth,
+16 -5
View File
@@ -189,6 +189,18 @@ async def lifespan(app: FastAPI):
finally:
db.close()
# Re-register OAuth / social-login providers now that DB settings are
# loaded. auth.py runs its initial registration at import time (before
# the lifespan runs), so providers that are only configured in the
# database would not be registered yet. Calling refresh here ensures
# they are active immediately on startup without any manual restart.
try:
from app.auth import refresh_social_providers
refresh_social_providers()
except Exception as e:
logging.warning(f"Could not refresh social login providers on startup: {e}")
# Initialize Sentry after DB settings are loaded so that values configured
# via the database UI (e.g. SENTRY_DSN) are respected in addition to env vars.
init_sentry()
@@ -412,15 +424,13 @@ 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(
"404.html", {"request": request}, status_code=status.HTTP_404_NOT_FOUND
)
return _error_templates.TemplateResponse(request, "404.html", 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,
)
@@ -440,8 +450,9 @@ async def custom_500_handler(request: Request, exc: Exception):
# Serve the 500 template for non-API routes
return _error_templates.TemplateResponse(
request,
"500.html",
{"request": request, "exc": exc},
context={"exc": exc},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
+12 -1
View File
@@ -7,8 +7,19 @@ 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, "rb") as f:
with open(filepath_obj, "rb") as f:
while True:
data = f.read(chunk_size)
if not data:
+5 -5
View File
@@ -27,8 +27,8 @@ def is_private_ip(hostname: str) -> bool:
return True
return False
except (socket.gaierror, socket.error):
# 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
# 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
+10
View File
@@ -71,6 +71,16 @@ def notify_settings_updated() -> None:
except Exception as exc:
logger.warning(f"Could not reload in-process settings: {exc}")
# Re-register OAuth / social-login providers so that any provider whose
# credentials were just saved (or updated) in the database is active
# immediately on the login page — no restart required.
try:
from app.auth import refresh_social_providers
refresh_social_providers()
except Exception as exc:
logger.warning(f"Could not refresh social login providers after settings update: {exc}")
# Re-check OCR language availability in the background whenever settings
# are updated. This ensures that if a user changes tesseract_language or
# easyocr_languages via the UI, the new language data is downloaded without
+29 -5
View File
@@ -162,12 +162,36 @@ 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"""
# 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):
"""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)
+57 -12
View File
@@ -206,8 +206,6 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
SSO settings, and service integrations through a wizard-like interface.
"""
try:
from app.auth import OAUTH_CONFIGURED, SOCIAL_PROVIDERS
db_settings = get_all_settings_from_db(db)
def _get_effective(key: str):
@@ -227,13 +225,14 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
services = []
# --- SSO (Authentik / OIDC) ---
_oidc_linked = bool(_get_effective("authentik_client_id") and _get_effective("authentik_client_secret"))
services.append(
{
"key": "oidc",
"name": settings.oauth_provider_name or "Single Sign-On",
"name": _get_effective("oauth_provider_name") or "Single Sign-On",
"icon": "fas fa-lock",
"type": "SSO",
"linked": OAUTH_CONFIGURED,
"linked": _oidc_linked,
"description": "OpenID Connect SSO provider",
"settings_keys": [
"authentik_client_id",
@@ -245,13 +244,23 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
)
# --- Google ---
_google_id = _get_effective("social_auth_google_client_id")
_google_secret = _get_effective("social_auth_google_client_secret")
if _is_truthy(_get_effective("social_auth_google_use_global_credentials")) and not (
_google_id and _google_secret
):
_google_id = _google_id or _get_effective("google_drive_client_id")
_google_secret = _google_secret or _get_effective("google_drive_client_secret")
_google_linked = bool(
_is_truthy(_get_effective("social_auth_google_enabled")) and _google_id and _google_secret
)
services.append(
{
"key": "google",
"name": "Google",
"icon": "fab fa-google",
"type": "Sign-in authentication",
"linked": "google" in SOCIAL_PROVIDERS,
"linked": _google_linked,
"description": "Sign-in authentication",
"settings_keys": [
"social_auth_google_enabled",
@@ -263,13 +272,18 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
)
# --- GitHub ---
_github_linked = bool(
_is_truthy(_get_effective("social_auth_github_enabled"))
and _get_effective("social_auth_github_client_id")
and _get_effective("social_auth_github_client_secret")
)
services.append(
{
"key": "github",
"name": "GitHub",
"icon": "fab fa-github",
"type": "Sign-in authentication",
"linked": "github" in SOCIAL_PROVIDERS,
"linked": _github_linked,
"description": "Sign-in authentication",
"settings_keys": [
"social_auth_github_enabled",
@@ -280,13 +294,19 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
)
# --- Microsoft ---
_ms_id = _get_effective("social_auth_microsoft_client_id")
_ms_secret = _get_effective("social_auth_microsoft_client_secret")
if _is_truthy(_get_effective("social_auth_microsoft_use_global_credentials")) and not (_ms_id and _ms_secret):
_ms_id = _ms_id or _get_effective("onedrive_client_id")
_ms_secret = _ms_secret or _get_effective("onedrive_client_secret")
_microsoft_linked = bool(_is_truthy(_get_effective("social_auth_microsoft_enabled")) and _ms_id and _ms_secret)
services.append(
{
"key": "microsoft",
"name": "Microsoft",
"icon": "fab fa-microsoft",
"type": "Sign-in authentication",
"linked": "microsoft" in SOCIAL_PROVIDERS,
"linked": _microsoft_linked,
"description": "Sign-in authentication",
"settings_keys": [
"social_auth_microsoft_enabled",
@@ -299,13 +319,18 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
)
# --- Apple ---
_apple_linked = bool(
_is_truthy(_get_effective("social_auth_apple_enabled"))
and _get_effective("social_auth_apple_client_id")
and _get_effective("social_auth_apple_team_id")
)
services.append(
{
"key": "apple",
"name": "Apple",
"icon": "fab fa-apple",
"type": "Sign-in authentication",
"linked": "apple" in SOCIAL_PROVIDERS,
"linked": _apple_linked,
"description": "Sign-in authentication",
"settings_keys": [
"social_auth_apple_enabled",
@@ -318,13 +343,19 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
)
# --- Dropbox ---
_dbx_id = _get_effective("social_auth_dropbox_client_id")
_dbx_secret = _get_effective("social_auth_dropbox_client_secret")
if _is_truthy(_get_effective("social_auth_dropbox_use_global_credentials")) and not (_dbx_id and _dbx_secret):
_dbx_id = _dbx_id or _get_effective("dropbox_app_key")
_dbx_secret = _dbx_secret or _get_effective("dropbox_app_secret")
_dropbox_linked = bool(_is_truthy(_get_effective("social_auth_dropbox_enabled")) and _dbx_id and _dbx_secret)
services.append(
{
"key": "dropbox",
"name": "Dropbox",
"icon": "fab fa-dropbox",
"type": "Sign-in authentication",
"linked": "dropbox" in SOCIAL_PROVIDERS,
"linked": _dropbox_linked,
"description": "Sign-in authentication",
"settings_keys": [
"social_auth_dropbox_enabled",
@@ -336,13 +367,20 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
)
# --- Keycloak ---
_keycloak_linked = bool(
_is_truthy(_get_effective("social_auth_keycloak_enabled"))
and _get_effective("social_auth_keycloak_client_id")
and _get_effective("social_auth_keycloak_client_secret")
and _get_effective("social_auth_keycloak_server_url")
and _get_effective("social_auth_keycloak_realm")
)
services.append(
{
"key": "keycloak",
"name": "Keycloak",
"icon": "fas fa-key",
"type": "SSO",
"linked": "keycloak" in SOCIAL_PROVIDERS,
"linked": _keycloak_linked,
"description": "SSO",
"settings_keys": [
"social_auth_keycloak_enabled",
@@ -355,13 +393,20 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
)
# --- Generic OAuth2 ---
_generic_oauth2_linked = bool(
_is_truthy(_get_effective("social_auth_generic_oauth2_enabled"))
and _get_effective("social_auth_generic_oauth2_client_id")
and _get_effective("social_auth_generic_oauth2_client_secret")
and _get_effective("social_auth_generic_oauth2_authorize_url")
and _get_effective("social_auth_generic_oauth2_token_url")
)
services.append(
{
"key": "generic_oauth2",
"name": "Generic OAuth2",
"icon": "fas fa-sign-in-alt",
"type": "SSO",
"linked": "generic_oauth2" in SOCIAL_PROVIDERS,
"linked": _generic_oauth2_linked,
"description": "SSO",
"settings_keys": [
"social_auth_generic_oauth2_enabled",
@@ -474,7 +519,7 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
"services": services,
"service_settings": service_settings,
"sso_auto_login": sso_auto_login,
"oauth_configured": OAUTH_CONFIGURED,
"oauth_configured": _oidc_linked,
"qr_login_enabled": qr_login_enabled,
"frontend_url_configured": frontend_url_configured,
"app_version": settings.version,
+2 -1
View File
@@ -23,6 +23,7 @@ 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",
{"request": request, "token": token},
context={"token": token},
)
+1 -1
View File
@@ -792,7 +792,7 @@ SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self'; style-src 'sel
SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline';"
```
**Note:** The default policy includes `'unsafe-inline'` for compatibility with Tailwind CSS and inline JavaScript. For stricter security, use nonces or hashes.
**Note:** The default policy includes `'unsafe-inline'` for compatibility with inline JavaScript. Tailwind CSS v3 is compiled at build time into a static file served from `'self'`, so no external style CDN is needed.
#### X-Frame-Options
+1 -1
View File
@@ -157,7 +157,7 @@ Recommended headers to configure at the proxy level:
#### Content-Security-Policy Notes
DocuElevate's frontend uses Tailwind CSS loaded from CDN in development mode. In production, ensure your CSP allows loading scripts and styles from your configured static file origin. A starting point:
DocuElevate's frontend uses Tailwind CSS v3 compiled at Docker build time. No external CDN requests are needed for CSS. In production, your CSP does not need to allow any external style sources beyond your own static file origin. A starting point:
```
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
+329
View File
@@ -0,0 +1,329 @@
/* frontend/input.css
* Tailwind CSS v3 source file.
* Edit this file (not static/styles.css) — the compiled output is
* generated by running: npm run build (inside the frontend/ directory)
*/
/* ── Tailwind layers ──────────────────────────────────────────────────────── */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Custom utilities ─────────────────────────────────────────────────────── */
/* =============================================================
ACCESSIBILITY
Skip-to-content link, focus indicators, and screen-reader-only
utility class following WCAG 2.1 Level AA requirements.
============================================================= */
/* Skip-to-content link: visible only on keyboard focus */
.skip-link {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
z-index: 9999;
padding: 0.75rem 1.5rem;
background-color: #1d4ed8;
color: #ffffff;
font-weight: 600;
text-decoration: none;
border-radius: 0 0 0.375rem 0;
}
.skip-link:focus {
position: fixed;
top: 0;
left: 0;
width: auto;
height: auto;
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* Enhanced focus-visible indicators for keyboard navigation (WCAG 2.4.7) */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* Screen-reader-only utility (visually hidden, accessible to AT) */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
body {
/* Your global overrides can go here if needed */
}
.material-symbols-light--folder-managed-outline {
display: inline-block;
width: 96px;
height: 96px;
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m17.212 20.404l-.108-.885q-.57-.125-.938-.33q-.368-.204-.7-.577l-.835.334l-.539-.815l.689-.577q-.165-.531-.165-1.035t.165-1.034l-.689-.577l.539-.816l.835.335q.332-.393.7-.588q.369-.195.938-.32l.108-.885h1l.107.885q.57.125.938.32t.7.588l.835-.335l.539.816l-.689.576q.166.531.166 1.035t-.166 1.035l.689.577l-.539.815l-.834-.335q-.333.373-.701.578q-.369.205-.938.33l-.107.885zm.5-1.731q.882 0 1.518-.635q.636-.636.636-1.519t-.636-1.518t-1.518-.636t-1.519.636t-.635 1.518t.635 1.519t1.518.635M4 18V6v4.435V10zm.616 1q-.691 0-1.153-.462T3 17.384V6.616q0-.691.463-1.153T4.615 5h4.981l2 2h7.789q.69 0 1.153.463T21 8.616v2.294q-.238-.152-.479-.265q-.24-.112-.521-.21v-1.82q0-.269-.173-.442T19.385 8h-8.19l-2-2h-4.58q-.269 0-.442.173T4 6.616v10.769q0 .269.173.442t.443.173h6.748q.055.275.131.515t.186.485z'/%3E%3C/svg%3E");
background-color: currentColor;
-webkit-mask-image: var(--svg);
mask-image: var(--svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
/* Ensure pagination wraps properly on small screens */
.pagination {
flex-wrap: wrap;
gap: 0.5rem;
}
.pagination-buttons {
flex-wrap: wrap;
}
/* Ensure filter items stack on very small screens */
@media (max-width: 480px) {
.filter-group {
flex-direction: column;
}
.filter-item {
min-width: unset;
width: 100%;
}
}
/* =============================================================
DARK MODE
Activated by "dark" class on <html> element.
Toggled by the navbar button; preference stored in localStorage.
Falls back to the server-side ui_default_color_scheme setting,
then to the OS prefers-color-scheme media query.
WCAG AA contrast ratios verified for all text/background pairs.
============================================================= */
/* Tell the browser we support both colour schemes */
html { color-scheme: light; }
html.dark { color-scheme: dark; }
/* ---- Base / Body ---- */
html.dark body { background-color: #111827; color: #e5e7eb; }
html.dark .bg-gray-50 { background-color: #111827; }
html.dark .bg-white { background-color: #1f2937; }
html.dark .bg-gray-100 { background-color: #374151; }
html.dark .bg-gray-200 { background-color: #4b5563; }
/* ---- Text colours ---- */
html.dark .text-gray-900 { color: #f9fafb; }
html.dark .text-gray-800 { color: #f3f4f6; }
html.dark .text-gray-700 { color: #e5e7eb; }
html.dark .text-gray-600 { color: #d1d5db; }
html.dark .text-gray-500 { color: #9ca3af; }
html.dark .text-gray-400 { color: #9ca3af; }
html.dark .text-black { color: #f9fafb; }
/* ---- Borders ---- */
html.dark .border-gray-100 { border-color: #374151; }
html.dark .border-gray-200 { border-color: #374151; }
html.dark .border-gray-300 { border-color: #4b5563; }
html.dark .border-gray-400 { border-color: #6b7280; }
html.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
html.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
html.dark .divide-y > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
/* ---- Hover states ---- */
html.dark .hover\:bg-gray-50:hover { background-color: #374151; }
html.dark .hover\:bg-gray-100:hover { background-color: #4b5563; }
html.dark .hover\:text-gray-900:hover { color: #f9fafb; }
html.dark .hover\:text-gray-700:hover { color: #e5e7eb; }
/* ---- Shadows (softened for dark mode) ---- */
html.dark .shadow,
html.dark .shadow-md,
html.dark .shadow-sm,
html.dark .shadow-lg {
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.6), 0 1px 2px 0 rgba(0,0,0,0.4);
}
/* ---- Alert / info-banner backgrounds ---- */
html.dark .bg-blue-50 { background-color: #1e3a5f; }
html.dark .bg-green-50 { background-color: #052e16; }
html.dark .bg-red-50 { background-color: #450a0a; }
html.dark .bg-yellow-50 { background-color: #451a03; }
html.dark .bg-indigo-50 { background-color: #1e1b4b; }
html.dark .bg-orange-50 { background-color: #431407; }
/* ---- Badge / pill backgrounds ---- */
html.dark .bg-blue-100 { background-color: #1e3a5f; }
html.dark .bg-green-100 { background-color: #052e16; }
html.dark .bg-red-100 { background-color: #450a0a; }
html.dark .bg-yellow-100 { background-color: #451a03; }
html.dark .bg-indigo-100 { background-color: #431407; }
html.dark .bg-orange-100 { background-color: #431407; }
html.dark .bg-purple-100 { background-color: #2e1065; }
/* ---- Status / badge text colours ---- */
html.dark .text-blue-700 { color: #93c5fd; }
html.dark .text-blue-800 { color: #bfdbfe; }
html.dark .text-green-700 { color: #86efac; }
html.dark .text-green-800 { color: #bbf7d0; }
html.dark .text-red-700 { color: #fca5a5; }
html.dark .text-red-800 { color: #fecaca; }
html.dark .text-yellow-700 { color: #fcd34d; }
html.dark .text-yellow-800 { color: #fde68a; }
html.dark .text-indigo-700 { color: #a5b4fc; }
html.dark .text-indigo-800 { color: #c7d2fe; }
html.dark .text-orange-700 { color: #fdba74; }
html.dark .text-orange-800 { color: #fed7aa; }
html.dark .text-purple-700 { color: #d8b4fe; }
html.dark .text-purple-800 { color: #e9d5ff; }
/* ---- Dropdown / popup menus ---- */
html.dark .bg-white.rounded-md.shadow-lg { background-color: #1f2937; }
html.dark .ring-black { --tw-ring-color: rgba(0,0,0,0.5); }
/* ---- Form inputs / selects / textareas ---- */
html.dark input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
html.dark select,
html.dark textarea {
background-color: #374151;
border-color: #4b5563;
color: #e5e7eb;
}
html.dark input::placeholder,
html.dark textarea::placeholder {
color: #9ca3af;
}
html.dark input:focus:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
html.dark select:focus,
html.dark textarea:focus {
border-color: #60a5fa;
outline-color: #60a5fa;
}
/* ---- Table rows ---- */
html.dark thead,
html.dark .bg-gray-50 thead { background-color: #1f2937; }
html.dark thead th { color: #9ca3af; }
html.dark tbody tr:hover { background-color: #374151; }
/* ---- Code / pre ---- */
html.dark pre,
html.dark code { background-color: #111827; color: #d1d5db; }
/* ---- Dark-mode toggle button icon colour ---- */
html.dark #darkModeToggle { color: #fbbf24; }
html.dark #darkModeToggle:hover { background-color: #374151; }
/* ---- Dark-mode skip-link ---- */
html.dark .skip-link { background-color: #2563eb; }
html.dark .skip-link:focus { outline-color: #60a5fa; }
/* ---- Dark-mode focus-visible indicators ---- */
html.dark a:focus-visible,
html.dark button:focus-visible,
html.dark input:focus-visible,
html.dark select:focus-visible,
html.dark textarea:focus-visible,
html.dark [tabindex]:focus-visible {
outline-color: #60a5fa;
}
/* ---- Scrollbar (WebKit browsers) ---- */
html.dark ::-webkit-scrollbar { width: 8px; height: 8px; }
html.dark ::-webkit-scrollbar-track { background: #1f2937; }
html.dark ::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
html.dark ::-webkit-scrollbar-thumb:hover { background: #6b7280; }
/* ---- Settings page: sidebar active state (dark) ---- */
html.dark .bg-blue-50 { background-color: #1e3a5f; }
/* =============================================================
DOC-TOGGLE cross-browser toggle switch
Implemented with custom CSS pseudo-elements so the appearance
is consistent across all browsers regardless of Tailwind version.
Usage:
<label class="doc-toggle">
<input type="checkbox" class="sr-only" onchange="...">
<span class="doc-toggle-track" aria-hidden="true"></span>
<span class="ml-3 ...">Label text</span>
</label>
============================================================= */
.doc-toggle {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
}
.doc-toggle-track {
position: relative;
display: inline-block;
width: 44px;
min-width: 44px;
height: 24px;
background-color: #e5e7eb; /* gray-200 */
border-radius: 9999px;
transition: background-color 0.2s ease-in-out;
flex-shrink: 0;
}
.doc-toggle-track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background-color: #ffffff;
border: 1px solid #d1d5db; /* gray-300 */
border-radius: 9999px;
transition: transform 0.2s ease-in-out, border-color 0.2s ease-in-out;
}
.doc-toggle input[type="checkbox"]:checked + .doc-toggle-track {
background-color: #4f46e5; /* indigo-600 */
}
.doc-toggle input[type="checkbox"]:checked + .doc-toggle-track::after {
transform: translateX(20px);
border-color: #ffffff;
}
.doc-toggle input[type="checkbox"]:focus-visible + .doc-toggle-track {
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px #6366f1; /* ring-2 ring-indigo-500 with offset */
}
/* Dark mode overrides */
html.dark .doc-toggle-track {
background-color: #374151; /* gray-700 */
}
html.dark .doc-toggle-track::after {
background-color: #ffffff;
border-color: #4b5563; /* gray-600 */
}
html.dark .doc-toggle input[type="checkbox"]:checked + .doc-toggle-track {
background-color: #4f46e5; /* indigo-600 */
}
html.dark .doc-toggle input[type="checkbox"]:checked + .doc-toggle-track::after {
border-color: #ffffff;
}
html.dark .doc-toggle input[type="checkbox"]:focus-visible + .doc-toggle-track {
box-shadow: 0 0 0 2px #111827, 0 0 0 4px #6366f1; /* dark background offset */
}
+1017
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "docuelevate-frontend",
"version": "1.0.0",
"description": "Frontend asset compilation for DocuElevate",
"scripts": {
"build": "tailwindcss -i input.css -o static/styles.css --minify",
"watch": "tailwindcss -i input.css -o static/styles.css --watch"
},
"devDependencies": {
"tailwindcss": "^3.4.0"
}
}
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./templates/**/*.html',
'./static/js/**/*.js',
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}
-2
View File
@@ -21,8 +21,6 @@
<!-- Alpine.js moved to head for earlier loading -->
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
{% block head_css %}
<!-- Tailwind CSS and other CSS -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ _("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" />
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Forgot Password</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Forgot Username</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+3 -15
View File
@@ -1379,27 +1379,15 @@ function integrationsDashboard() {
async testSavedIntegration(intg) {
this.testingId = intg.id;
try {
// Retrieve saved credentials to test
const credsResp = await fetch(`/api/integrations/${intg.id}/credentials`);
if (!credsResp.ok) {
this.showAlert('error', 'Test Failed', 'Could not retrieve saved credentials for testing.');
return;
}
const creds = await credsResp.json();
const resp = await fetch('/api/integrations/test', {
const resp = await fetch(`/api/integrations/${intg.id}/test`, {
method: 'POST',
headers: authHeaders(true),
body: JSON.stringify({
integration_type: intg.integration_type,
config: intg.config,
credentials: creds.credentials,
}),
});
const data = await resp.json();
if (data.success) {
if (resp.ok && data.success) {
this.showAlert('success', `${intg.name}: Connection OK`, data.message);
} else {
this.showAlert('error', `${intg.name}: Connection Failed`, data.message);
this.showAlert('error', `${intg.name}: Connection Failed`, data.message || {{ _("integrations.connection_test_failed_fallback")|tojson }});
}
} catch (err) {
this.showAlert('error', 'Test Failed', `Network error: ${err.message || 'Unknown error'}`);
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ _("app.name") }} - {{ _("auth.login_title") }}</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Reset Password</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<title>Shared Document DocuElevate</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Tailwind CSS -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
<!-- Tailwind CSS v3 (compiled) -->
<link rel="stylesheet" href="/static/styles.css" />
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Create Account</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ _("auth.verify_email_page_title") }}</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+1
View File
@@ -1107,6 +1107,7 @@
"integrations.connected": "Connected",
"integrations.connection_failed": "Connection failed",
"integrations.connection_success": "Connection successful",
"integrations.connection_test_failed_fallback": "Connection test failed.",
"integrations.delete_confirm_are_you_sure": "Are you sure you want to delete",
"integrations.delete_confirm_title": "Delete Integration?",
"integrations.delete_confirm_undone": "This action cannot be undone.",
+58 -18
View File
@@ -1,6 +1,7 @@
"""Tests for the per-user integrations API (app/api/integrations.py)."""
import unittest.mock
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
@@ -405,32 +406,33 @@ class TestDeleteIntegration:
@pytest.mark.integration
class TestGetIntegrationCredentials:
"""Tests for GET /api/integrations/{id}/credentials."""
class TestTestSavedIntegrationConnection:
"""Tests for POST /api/integrations/{id}/test."""
@patch("app.api.integrations._CONNECTION_TESTERS")
def test_test_saved_integration_success(self, mock_testers, int_client):
"""Test a saved integration successfully."""
mock_tester = MagicMock(return_value={"success": True, "message": "OK"})
mock_testers.get.return_value = mock_tester
def test_returns_decrypted_credentials(self, int_client):
"""Credentials endpoint returns the decrypted dict."""
created = int_client.post("/api/integrations/", json=_IMAP_SOURCE).json()
resp = int_client.get(f"/api/integrations/{created['id']}/credentials")
assert resp.status_code == 200
creds = resp.json()["credentials"]
assert creds["password"] == "s3cr3t" # noqa: S105
resp = int_client.post(f"/api/integrations/{created['id']}/test")
def test_returns_empty_dict_when_no_credentials(self, int_client):
"""No credentials stored returns empty dict."""
payload = dict(_IMAP_SOURCE, credentials=None)
created = int_client.post("/api/integrations/", json=payload).json()
resp = int_client.get(f"/api/integrations/{created['id']}/credentials")
assert resp.status_code == 200
assert resp.json()["credentials"] == {}
assert resp.json()["success"] is True
mock_testers.get.assert_called_with("IMAP")
mock_tester.assert_called_once()
args = mock_tester.call_args[0]
assert args[0]["host"] == "imap.gmail.com"
assert args[1]["password"] == "s3cr3t"
def test_not_found(self, int_client):
"""Non-existent integration returns 404."""
resp = int_client.get("/api/integrations/9999/credentials")
resp = int_client.post("/api/integrations/9999/test")
assert resp.status_code == 404
def test_other_users_credentials_returns_404(self, int_client, int_session):
"""Cannot retrieve another user's credentials."""
def test_other_users_integration_returns_404(self, int_client, int_session):
"""Cannot test another user's integration."""
other_integration = UserIntegration(
owner_id=_OTHER_OWNER,
direction="SOURCE",
@@ -441,9 +443,47 @@ class TestGetIntegrationCredentials:
)
int_session.add(other_integration)
int_session.commit()
resp = int_client.get(f"/api/integrations/{other_integration.id}/credentials")
resp = int_client.post(f"/api/integrations/{other_integration.id}/test")
assert resp.status_code == 404
@patch("app.api.integrations._CONNECTION_TESTERS")
def test_invalid_json_config_returns_failure(self, mock_testers, int_client, int_session):
"""Invalid JSON in config returns a controlled failure response."""
mock_tester = MagicMock()
mock_testers.get.return_value = mock_tester
bad_integration = UserIntegration(
owner_id=_OWNER,
direction="SOURCE",
integration_type="IMAP",
name="Bad Config",
config="not-valid-json{{{",
credentials="{}",
is_active=True,
)
int_session.add(bad_integration)
int_session.commit()
resp = int_client.post(f"/api/integrations/{bad_integration.id}/test")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "invalid" in data["message"].lower()
mock_tester.assert_not_called()
@patch("app.api.integrations._CONNECTION_TESTERS")
def test_tester_raises_exception_returns_failure(self, mock_testers, int_client):
"""Tester that raises an exception returns a controlled failure response."""
mock_testers.get.return_value = MagicMock(side_effect=ValueError("bad port"))
created = int_client.post("/api/integrations/", json=_IMAP_SOURCE).json()
resp = int_client.post(f"/api/integrations/{created['id']}/test")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "unexpected error" in data["message"].lower()
@pytest.mark.unit
class TestIntegrationModel:
+3 -3
View File
@@ -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][0] == "login.html"
context = call_args[0][1]
assert call_args[0][1] == "login.html"
context = call_args.kwargs["context"]
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[0][1]
context = call_args.kwargs["context"]
assert context["error"] is None
assert context["message"] is None
+1 -1
View File
@@ -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[0][1]
context = call_args.kwargs["context"]
assert context["show_oauth"] is True
assert context["oauth_provider_name"] == "Test SSO"
+169 -2
View File
@@ -268,8 +268,6 @@ class TestConnectionsPageRoute:
patch("app.views.settings.get_all_settings_from_db", return_value={}),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.auth.OAUTH_CONFIGURED", False),
patch("app.auth.SOCIAL_PROVIDERS", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
@@ -296,6 +294,175 @@ class TestConnectionsPageRoute:
assert "smtp" in service_keys
assert "telegram" in service_keys
@pytest.mark.asyncio
async def test_connections_page_linked_status_from_db(self):
"""Linked status is derived from DB/effective settings, not SOCIAL_PROVIDERS."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
# Simulate GitHub configured only in DB (not in SOCIAL_PROVIDERS yet)
db_values = {
"social_auth_github_enabled": "true",
"social_auth_github_client_id": "gh-id",
"social_auth_github_client_secret": "gh-secret",
}
with (
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
await connections_page(mock_request, db=mock_db)
context = mock_templates.TemplateResponse.call_args[0][1]
services_by_key = {s["key"]: s for s in context["services"]}
# GitHub should be linked because DB values say so
assert services_by_key["github"]["linked"] is True
@pytest.mark.asyncio
async def test_connections_page_unlinked_when_credentials_missing(self):
"""Provider is unlinked when enabled=true but credentials are absent."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
# enabled but no credentials
db_values = {"social_auth_github_enabled": "true"}
with (
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
await connections_page(mock_request, db=mock_db)
context = mock_templates.TemplateResponse.call_args[0][1]
services_by_key = {s["key"]: s for s in context["services"]}
assert services_by_key["github"]["linked"] is False
@pytest.mark.asyncio
async def test_connections_page_oidc_linked_from_db(self):
"""OIDC linked status derives from DB effective settings."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
db_values = {
"authentik_client_id": "my-client-id",
"authentik_client_secret": "my-secret",
"oauth_provider_name": "My SSO",
}
with (
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
await connections_page(mock_request, db=mock_db)
context = mock_templates.TemplateResponse.call_args[0][1]
services_by_key = {s["key"]: s for s in context["services"]}
assert services_by_key["oidc"]["linked"] is True
assert services_by_key["oidc"]["name"] == "My SSO"
# oauth_configured template var should also reflect the DB state
assert context["oauth_configured"] is True
@pytest.mark.unit
class TestRefreshSocialProviders:
"""Tests for the refresh_social_providers() mechanism."""
def test_refresh_social_providers_exists(self):
"""refresh_social_providers is importable from app.auth."""
from app.auth import refresh_social_providers
assert callable(refresh_social_providers)
def test_refresh_social_providers_clears_and_repopulates(self):
"""After refresh, SOCIAL_PROVIDERS reflects current settings."""
import app.auth as auth_module
with (
patch.object(auth_module, "AUTH_ENABLED", True),
patch.object(auth_module, "settings") as mock_settings,
):
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
mock_settings.social_auth_google_enabled = True
mock_settings.social_auth_google_client_id = "gid"
mock_settings.social_auth_google_client_secret = "gsecret"
mock_settings.social_auth_google_use_global_credentials = False
# All other providers disabled
for attr in (
"social_auth_microsoft_enabled",
"social_auth_apple_enabled",
"social_auth_dropbox_enabled",
"social_auth_github_enabled",
"social_auth_keycloak_enabled",
"social_auth_generic_oauth2_enabled",
):
setattr(mock_settings, attr, False)
with patch.object(auth_module, "_register_oauth_client"):
auth_module._setup_social_providers()
assert "google" in auth_module.SOCIAL_PROVIDERS
assert auth_module.OAUTH_CONFIGURED is False
def test_refresh_clears_previous_providers(self):
"""Providers removed from settings are cleared after refresh."""
import app.auth as auth_module
# Pre-populate with a stale entry
auth_module.SOCIAL_PROVIDERS["stale_provider"] = {"name": "Stale", "icon": "", "color": ""}
with (
patch.object(auth_module, "AUTH_ENABLED", True),
patch.object(auth_module, "settings") as mock_settings,
):
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
for attr in (
"social_auth_google_enabled",
"social_auth_microsoft_enabled",
"social_auth_apple_enabled",
"social_auth_dropbox_enabled",
"social_auth_github_enabled",
"social_auth_keycloak_enabled",
"social_auth_generic_oauth2_enabled",
):
setattr(mock_settings, attr, False)
with patch.object(auth_module, "_register_oauth_client"):
auth_module._setup_social_providers()
assert "stale_provider" not in auth_module.SOCIAL_PROVIDERS
def test_register_oauth_client_clears_cache(self):
"""_register_oauth_client removes the cached client before re-registering."""
import app.auth as auth_module
# Inject a fake cached client
auth_module.oauth._clients["test_provider"] = object()
with patch.object(auth_module.oauth, "register"):
auth_module._register_oauth_client("test_provider", client_id="x", client_secret="y")
assert "test_provider" not in auth_module.oauth._clients
@pytest.mark.unit
class TestTranslationKeys:
+2 -2
View File
@@ -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 (lines 67-72)."""
"""Cover DNS resolution failure branch blocking unresolvable domains."""
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 False
assert result is True # Fail securely by returning True
def test_is_private_ip_hostname_resolves_to_private(self):
"""Cover branch where hostname resolves to a private IP (line 64-65)."""
+3 -2
View File
@@ -69,8 +69,9 @@ class TestViewsBase:
context = {"request": req}
template_response_with_version("template.html", context)
args, _ = mock_orig.call_args
assert args[1].get("csrf_token") == "my-csrf"
args, kwargs = mock_orig.call_args
context = kwargs.get("context", {})
assert context.get("csrf_token") == "my-csrf"
def test_kwargs_context_no_request(self):
"""Test kwargs context path when request is not in context."""
+4 -4
View File
@@ -55,8 +55,8 @@ class TestDarkModeTemplateInjection:
captured = {}
def fake_original(name, ctx, **kw):
captured.update(ctx)
def fake_original(request_obj, name, context=None, **kw):
captured.update(context or {})
with patch("app.views.base.original_template_response", side_effect=fake_original):
mock_request = MagicMock()
@@ -73,8 +73,8 @@ class TestDarkModeTemplateInjection:
captured = {}
def fake_original(name, ctx, **kw):
captured.update(ctx)
def fake_original(request_obj, name, context=None, **kw):
captured.update(context or {})
with patch("app.views.base.original_template_response", side_effect=fake_original):
mock_request = MagicMock()
+145
View File
@@ -0,0 +1,145 @@
"""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"
+2 -2
View File
@@ -345,7 +345,7 @@ class TestLoginPageSocialProviders:
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
context = call_args[0][1]
context = call_args.kwargs.get("context", {})
assert context["social_providers"] == mock_providers
@pytest.mark.asyncio
@@ -371,7 +371,7 @@ class TestLoginPageSocialProviders:
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
context = call_args[0][1]
context = call_args.kwargs.get("context", {})
assert context["social_providers"] == {}
+12
View File
@@ -698,6 +698,18 @@ 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)"""