Merge pull request #587 from christianlouis/copilot/add-apple-drive-icloud-support
feat(storage): add Apple iCloud Drive storage provider
This commit is contained in:
@@ -399,6 +399,15 @@ SFTP_PASSWORD=your_secure_sftp_password
|
|||||||
SFTP_FOLDER=/Documents/Uploads
|
SFTP_FOLDER=/Documents/Uploads
|
||||||
SFTP_DISABLE_HOST_KEY_VERIFICATION=False # Default is False (secure); set to True only for testing
|
SFTP_DISABLE_HOST_KEY_VERIFICATION=False # Default is False (secure); set to True only for testing
|
||||||
|
|
||||||
|
# iCloud Drive
|
||||||
|
# Requires an Apple ID with iCloud Drive enabled.
|
||||||
|
# For accounts with two-factor authentication (most accounts), generate an
|
||||||
|
# app-specific password at https://appleid.apple.com/account/manage
|
||||||
|
ICLOUD_USERNAME=your_apple_id@example.com
|
||||||
|
ICLOUD_PASSWORD=your-app-specific-password
|
||||||
|
ICLOUD_FOLDER=Documents/Uploads
|
||||||
|
# ICLOUD_COOKIE_DIRECTORY=/path/to/cookie/dir # Optional: defaults to ~/.pyicloud
|
||||||
|
|
||||||
# **HTTP Request Settings**
|
# **HTTP Request Settings**
|
||||||
# Timeout for HTTP requests - set higher to handle large PDF files (up to 1GB)
|
# Timeout for HTTP requests - set higher to handle large PDF files (up to 1GB)
|
||||||
HTTP_REQUEST_TIMEOUT=120 # Timeout in seconds (default: 120 for large file operations)
|
HTTP_REQUEST_TIMEOUT=120 # Timeout in seconds (default: 120 for large file operations)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from app.tasks.upload_to_dropbox import upload_to_dropbox # noqa: F401
|
|||||||
from app.tasks.upload_to_email import upload_to_email # noqa: F401
|
from app.tasks.upload_to_email import upload_to_email # noqa: F401
|
||||||
from app.tasks.upload_to_ftp import upload_to_ftp # noqa: F401
|
from app.tasks.upload_to_ftp import upload_to_ftp # noqa: F401
|
||||||
from app.tasks.upload_to_google_drive import upload_to_google_drive # noqa: F401
|
from app.tasks.upload_to_google_drive import upload_to_google_drive # noqa: F401
|
||||||
|
from app.tasks.upload_to_icloud import upload_to_icloud # noqa: F401
|
||||||
from app.tasks.upload_to_nextcloud import upload_to_nextcloud # noqa: F401
|
from app.tasks.upload_to_nextcloud import upload_to_nextcloud # noqa: F401
|
||||||
from app.tasks.upload_to_onedrive import upload_to_onedrive # noqa: F401
|
from app.tasks.upload_to_onedrive import upload_to_onedrive # noqa: F401
|
||||||
from app.tasks.upload_to_paperless import upload_to_paperless # noqa: F401
|
from app.tasks.upload_to_paperless import upload_to_paperless # noqa: F401
|
||||||
|
|||||||
@@ -466,6 +466,12 @@ class Settings(BaseSettings):
|
|||||||
s3_storage_class: Optional[str] = "STANDARD" # Default storage class
|
s3_storage_class: Optional[str] = "STANDARD" # Default storage class
|
||||||
s3_acl: Optional[str] = "private" # Default ACL
|
s3_acl: Optional[str] = "private" # Default ACL
|
||||||
|
|
||||||
|
# iCloud Drive settings
|
||||||
|
icloud_username: Optional[str] = None # Apple ID email address
|
||||||
|
icloud_password: Optional[str] = None # App-specific password (required for 2FA accounts)
|
||||||
|
icloud_folder: Optional[str] = None # Target folder path in iCloud Drive (e.g. "Documents/Uploads")
|
||||||
|
icloud_cookie_directory: Optional[str] = None # Directory for session cookies (default: ~/.pyicloud)
|
||||||
|
|
||||||
# Uptime Kuma settings
|
# Uptime Kuma settings
|
||||||
uptime_kuma_url: Optional[str] = None
|
uptime_kuma_url: Optional[str] = None
|
||||||
uptime_kuma_ping_interval: int = 5 # Default ping interval in minutes
|
uptime_kuma_ping_interval: int = 5 # Default ping interval in minutes
|
||||||
|
|||||||
@@ -530,6 +530,7 @@ class IntegrationType:
|
|||||||
EMAIL = "EMAIL"
|
EMAIL = "EMAIL"
|
||||||
PAPERLESS = "PAPERLESS"
|
PAPERLESS = "PAPERLESS"
|
||||||
RCLONE = "RCLONE"
|
RCLONE = "RCLONE"
|
||||||
|
ICLOUD = "ICLOUD"
|
||||||
|
|
||||||
ALL = {
|
ALL = {
|
||||||
IMAP,
|
IMAP,
|
||||||
@@ -546,6 +547,7 @@ class IntegrationType:
|
|||||||
EMAIL,
|
EMAIL,
|
||||||
PAPERLESS,
|
PAPERLESS,
|
||||||
RCLONE,
|
RCLONE,
|
||||||
|
ICLOUD,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.tasks.upload_to_dropbox import upload_to_dropbox
|
|||||||
from app.tasks.upload_to_email import upload_to_email
|
from app.tasks.upload_to_email import upload_to_email
|
||||||
from app.tasks.upload_to_ftp import upload_to_ftp
|
from app.tasks.upload_to_ftp import upload_to_ftp
|
||||||
from app.tasks.upload_to_google_drive import upload_to_google_drive
|
from app.tasks.upload_to_google_drive import upload_to_google_drive
|
||||||
|
from app.tasks.upload_to_icloud import upload_to_icloud
|
||||||
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
|
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
|
||||||
from app.tasks.upload_to_onedrive import upload_to_onedrive
|
from app.tasks.upload_to_onedrive import upload_to_onedrive
|
||||||
from app.tasks.upload_to_paperless import upload_to_paperless
|
from app.tasks.upload_to_paperless import upload_to_paperless
|
||||||
@@ -79,6 +80,10 @@ def _should_upload_to_s3():
|
|||||||
return bool(settings.s3_bucket_name and settings.aws_access_key_id and settings.aws_secret_access_key)
|
return bool(settings.s3_bucket_name and settings.aws_access_key_id and settings.aws_secret_access_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_upload_to_icloud():
|
||||||
|
return bool(settings.icloud_username and settings.icloud_password)
|
||||||
|
|
||||||
|
|
||||||
def get_configured_services_from_validator():
|
def get_configured_services_from_validator():
|
||||||
"""
|
"""
|
||||||
Use the config validator to determine which services are configured properly.
|
Use the config validator to determine which services are configured properly.
|
||||||
@@ -98,6 +103,7 @@ def get_configured_services_from_validator():
|
|||||||
"Email": "email",
|
"Email": "email",
|
||||||
"OneDrive": "onedrive",
|
"OneDrive": "onedrive",
|
||||||
"S3 Storage": "s3",
|
"S3 Storage": "s3",
|
||||||
|
"iCloud Drive": "icloud",
|
||||||
}
|
}
|
||||||
|
|
||||||
result = {}
|
result = {}
|
||||||
@@ -206,6 +212,11 @@ def send_to_all_destinations(self, file_path: str, use_validator=True, file_id:
|
|||||||
"should_upload": _should_upload_to_s3,
|
"should_upload": _should_upload_to_s3,
|
||||||
"upload_func": upload_to_s3,
|
"upload_func": upload_to_s3,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "icloud",
|
||||||
|
"should_upload": _should_upload_to_icloud,
|
||||||
|
"upload_func": upload_to_icloud,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Optionally get configuration status from validator
|
# Optionally get configuration status from validator
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Upload files to Apple iCloud Drive via the pyicloud library.
|
||||||
|
|
||||||
|
This module uses the ``pyicloud`` library to authenticate with Apple's iCloud
|
||||||
|
service and upload files to iCloud Drive. Because Apple does not offer a public
|
||||||
|
REST API for iCloud Drive, this integration relies on the *unofficial*
|
||||||
|
reverse-engineered protocol implemented by ``pyicloud``.
|
||||||
|
|
||||||
|
Requirements
|
||||||
|
~~~~~~~~~~~~
|
||||||
|
* An Apple ID with iCloud Drive enabled.
|
||||||
|
* An **app-specific password** generated at https://appleid.apple.com (required
|
||||||
|
when two-factor authentication is active – which is the default for all modern
|
||||||
|
Apple IDs).
|
||||||
|
* The ``pyicloud`` Python package (``pip install pyicloud``).
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
~~~~~~~~~~~~~
|
||||||
|
Set the following environment variables (or ``app/config.py`` fields):
|
||||||
|
|
||||||
|
* ``ICLOUD_USERNAME`` – Apple ID email address.
|
||||||
|
* ``ICLOUD_PASSWORD`` – App-specific password.
|
||||||
|
* ``ICLOUD_FOLDER`` – Target folder path inside iCloud Drive, using ``/`` as
|
||||||
|
the separator (e.g. ``Documents/Uploads``). The folder is created
|
||||||
|
automatically if it does not exist.
|
||||||
|
* ``ICLOUD_COOKIE_DIRECTORY`` – (Optional) Directory for persisting session
|
||||||
|
cookies so that re-authentication is avoided between task runs. Defaults to
|
||||||
|
``~/.pyicloud``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.celery_app import celery
|
||||||
|
from app.config import settings
|
||||||
|
from app.tasks.retry_config import UploadTaskWithRetry
|
||||||
|
from app.utils import log_task_progress
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_icloud_api(
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
cookie_directory: str | None = None,
|
||||||
|
):
|
||||||
|
"""Return an authenticated ``PyiCloudService`` instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Apple ID email address.
|
||||||
|
password: App-specific password.
|
||||||
|
cookie_directory: Optional directory for session cookies.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An authenticated ``PyiCloudService`` instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImportError: If ``pyicloud`` is not installed.
|
||||||
|
ValueError: If authentication fails or 2FA is required interactively.
|
||||||
|
"""
|
||||||
|
from pyicloud import PyiCloudService # noqa: S404 – unofficial third-party iCloud client
|
||||||
|
|
||||||
|
kwargs: dict = {}
|
||||||
|
if cookie_directory:
|
||||||
|
kwargs["cookie_directory"] = cookie_directory
|
||||||
|
|
||||||
|
api = PyiCloudService(username, password, **kwargs)
|
||||||
|
|
||||||
|
# If 2SA/2FA is required the user must use an app-specific password instead.
|
||||||
|
if api.requires_2sa or api.requires_2fa:
|
||||||
|
raise ValueError(
|
||||||
|
"iCloud account requires two-factor authentication. "
|
||||||
|
"Please generate an app-specific password at https://appleid.apple.com "
|
||||||
|
"and use it as ICLOUD_PASSWORD."
|
||||||
|
)
|
||||||
|
|
||||||
|
return api
|
||||||
|
|
||||||
|
|
||||||
|
def _navigate_to_folder(drive_root, folder_path: str):
|
||||||
|
"""Navigate into (or create) the folder hierarchy described by *folder_path*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
drive_root: The iCloud Drive root node (``api.drive``).
|
||||||
|
folder_path: ``/``-separated path such as ``Documents/Uploads``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The drive node representing the target folder.
|
||||||
|
"""
|
||||||
|
node = drive_root
|
||||||
|
if not folder_path:
|
||||||
|
return node
|
||||||
|
|
||||||
|
parts = [p for p in folder_path.strip("/").split("/") if p]
|
||||||
|
for part in parts:
|
||||||
|
children = {child.name: child for child in node.dir()}
|
||||||
|
if part in children:
|
||||||
|
node = children[part]
|
||||||
|
else:
|
||||||
|
# Create the missing folder
|
||||||
|
node = node.mkdir(part)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(base=UploadTaskWithRetry, bind=True)
|
||||||
|
def upload_to_icloud(self, file_path: str, file_id: int = None, folder_override: str = None):
|
||||||
|
"""Upload a file to Apple iCloud Drive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Local path to the file to upload.
|
||||||
|
file_id: Optional ``FileRecord.id`` for progress logging.
|
||||||
|
folder_override: If provided, overrides the default ``ICLOUD_FOLDER``
|
||||||
|
setting for this upload.
|
||||||
|
"""
|
||||||
|
task_id = self.request.id
|
||||||
|
logger.info(f"[{task_id}] Starting iCloud Drive upload: {file_path}")
|
||||||
|
log_task_progress(
|
||||||
|
task_id,
|
||||||
|
"upload_to_icloud",
|
||||||
|
"in_progress",
|
||||||
|
f"Uploading to iCloud Drive: {os.path.basename(file_path)}",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Validate inputs
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
error_msg = f"File not found: {file_path}"
|
||||||
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
|
log_task_progress(task_id, "upload_to_icloud", "failure", error_msg, file_id=file_id)
|
||||||
|
raise FileNotFoundError(error_msg)
|
||||||
|
|
||||||
|
if not settings.icloud_username or not settings.icloud_password:
|
||||||
|
error_msg = "iCloud credentials are not configured (ICLOUD_USERNAME / ICLOUD_PASSWORD)"
|
||||||
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
|
log_task_progress(task_id, "upload_to_icloud", "failure", error_msg, file_id=file_id)
|
||||||
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
|
filename = os.path.basename(file_path)
|
||||||
|
target_folder = folder_override if folder_override is not None else (settings.icloud_folder or "")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Authenticate & upload
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
try:
|
||||||
|
api = _get_icloud_api(
|
||||||
|
settings.icloud_username,
|
||||||
|
settings.icloud_password,
|
||||||
|
settings.icloud_cookie_directory,
|
||||||
|
)
|
||||||
|
|
||||||
|
folder_node = _navigate_to_folder(api.drive, target_folder)
|
||||||
|
|
||||||
|
with open(file_path, "rb") as fh:
|
||||||
|
folder_node.upload(fh)
|
||||||
|
|
||||||
|
logger.info(f"[{task_id}] Successfully uploaded {filename} to iCloud Drive folder '{target_folder}'")
|
||||||
|
log_task_progress(
|
||||||
|
task_id,
|
||||||
|
"upload_to_icloud",
|
||||||
|
"success",
|
||||||
|
f"Uploaded to iCloud Drive: {filename}",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "Completed",
|
||||||
|
"file": file_path,
|
||||||
|
"icloud_folder": target_folder or "/",
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error uploading {filename} to iCloud Drive: {e}"
|
||||||
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
|
log_task_progress(task_id, "upload_to_icloud", "failure", error_msg, file_id=file_id)
|
||||||
|
raise RuntimeError(error_msg) from e
|
||||||
@@ -571,6 +571,37 @@ def _upload_rclone(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], t
|
|||||||
return {"status": "Completed", "rclone_dest": dest}
|
return {"status": "Completed", "rclone_dest": dest}
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_icloud(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
|
||||||
|
"""Upload *file_path* to iCloud Drive using per-user credentials.
|
||||||
|
|
||||||
|
Expected *cfg* keys:
|
||||||
|
* ``folder`` – target folder path inside iCloud Drive (e.g. ``Documents/Uploads``).
|
||||||
|
* ``cookie_directory`` – (optional) path for session cookie persistence.
|
||||||
|
|
||||||
|
Expected *creds* keys:
|
||||||
|
* ``username`` – Apple ID email address.
|
||||||
|
* ``password`` – app-specific password.
|
||||||
|
"""
|
||||||
|
from app.tasks.upload_to_icloud import _get_icloud_api, _navigate_to_folder
|
||||||
|
|
||||||
|
username = creds.get("username") or ""
|
||||||
|
password = creds.get("password") or ""
|
||||||
|
folder = cfg.get("folder") or ""
|
||||||
|
cookie_directory = cfg.get("cookie_directory") or None
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
raise ValueError("iCloud integration is missing username or password in credentials")
|
||||||
|
|
||||||
|
api = _get_icloud_api(username, password, cookie_directory)
|
||||||
|
folder_node = _navigate_to_folder(api.drive, folder)
|
||||||
|
|
||||||
|
with open(file_path, "rb") as fh:
|
||||||
|
folder_node.upload(fh)
|
||||||
|
|
||||||
|
logger.info("[%s] iCloud Drive upload complete: folder=%s", task_id, folder or "/")
|
||||||
|
return {"status": "Completed", "icloud_folder": folder or "/"}
|
||||||
|
|
||||||
|
|
||||||
# Map IntegrationType → upload helper
|
# Map IntegrationType → upload helper
|
||||||
_UPLOAD_HANDLERS = {
|
_UPLOAD_HANDLERS = {
|
||||||
IntegrationType.DROPBOX: _upload_dropbox,
|
IntegrationType.DROPBOX: _upload_dropbox,
|
||||||
@@ -584,6 +615,7 @@ _UPLOAD_HANDLERS = {
|
|||||||
IntegrationType.PAPERLESS: _upload_paperless,
|
IntegrationType.PAPERLESS: _upload_paperless,
|
||||||
IntegrationType.EMAIL: _upload_email,
|
IntegrationType.EMAIL: _upload_email,
|
||||||
IntegrationType.RCLONE: _upload_rclone,
|
IntegrationType.RCLONE: _upload_rclone,
|
||||||
|
IntegrationType.ICLOUD: _upload_icloud,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -373,4 +373,19 @@ def get_provider_status() -> dict[str, dict[str, object]]:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check iCloud Drive configuration
|
||||||
|
providers["iCloud Drive"] = {
|
||||||
|
"name": "iCloud Drive",
|
||||||
|
"icon": "fa-brands fa-apple",
|
||||||
|
"configured": bool(getattr(settings, "icloud_username", None) and getattr(settings, "icloud_password", None)),
|
||||||
|
"enabled": True,
|
||||||
|
"description": "Store documents in Apple iCloud Drive",
|
||||||
|
"details": {
|
||||||
|
"username": getattr(settings, "icloud_username", "Not set"),
|
||||||
|
"password": mask_sensitive_value(getattr(settings, "icloud_password", None)),
|
||||||
|
"folder": getattr(settings, "icloud_folder", "Not set"),
|
||||||
|
"cookie_directory": getattr(settings, "icloud_cookie_directory", "Not set"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return providers
|
return providers
|
||||||
|
|||||||
@@ -838,6 +838,39 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
# Storage Providers - iCloud Drive
|
||||||
|
"icloud_username": {
|
||||||
|
"category": "Storage Providers",
|
||||||
|
"description": "Apple ID email address for iCloud Drive authentication",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": False,
|
||||||
|
},
|
||||||
|
"icloud_password": {
|
||||||
|
"category": "Storage Providers",
|
||||||
|
"description": "App-specific password for iCloud Drive (generate at https://appleid.apple.com)",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": True,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": False,
|
||||||
|
},
|
||||||
|
"icloud_folder": {
|
||||||
|
"category": "Storage Providers",
|
||||||
|
"description": "Target folder path in iCloud Drive (e.g. Documents/Uploads)",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": False,
|
||||||
|
},
|
||||||
|
"icloud_cookie_directory": {
|
||||||
|
"category": "Storage Providers",
|
||||||
|
"description": "Directory for persisting iCloud session cookies (default: ~/.pyicloud)",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": False,
|
||||||
|
},
|
||||||
# Storage Providers - AWS S3
|
# Storage Providers - AWS S3
|
||||||
"aws_access_key_id": {
|
"aws_access_key_id": {
|
||||||
"category": "Storage Providers",
|
"category": "Storage Providers",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ _DESTINATION_META: list[dict] = [
|
|||||||
{"id": "webdav", "name": "WebDAV", "icon": "fas fa-server"},
|
{"id": "webdav", "name": "WebDAV", "icon": "fas fa-server"},
|
||||||
{"id": "sftp", "name": "SFTP", "icon": "fas fa-terminal"},
|
{"id": "sftp", "name": "SFTP", "icon": "fas fa-terminal"},
|
||||||
{"id": "ftp", "name": "FTP", "icon": "fas fa-server"},
|
{"id": "ftp", "name": "FTP", "icon": "fas fa-server"},
|
||||||
|
{"id": "icloud", "name": "iCloud Drive", "icon": "fab fa-apple"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ def _get_configured_destinations(cfg: Settings) -> list[dict]:
|
|||||||
"webdav": bool(cfg.webdav_url and cfg.webdav_username),
|
"webdav": bool(cfg.webdav_url and cfg.webdav_username),
|
||||||
"sftp": bool(cfg.sftp_host and cfg.sftp_username),
|
"sftp": bool(cfg.sftp_host and cfg.sftp_username),
|
||||||
"ftp": bool(cfg.ftp_host and cfg.ftp_username),
|
"ftp": bool(cfg.ftp_host and cfg.ftp_username),
|
||||||
|
"icloud": bool(cfg.icloud_username and cfg.icloud_password),
|
||||||
}
|
}
|
||||||
return [meta for meta in _DESTINATION_META if checks.get(meta["id"], False)]
|
return [meta for meta in _DESTINATION_META if checks.get(meta["id"], False)]
|
||||||
|
|
||||||
|
|||||||
@@ -1108,6 +1108,22 @@ For detailed setup instructions, see the [OneDrive Setup Guide](OneDriveSetup.md
|
|||||||
|
|
||||||
For detailed setup instructions, see the [Amazon S3 Setup Guide](AmazonS3Setup.md).
|
For detailed setup instructions, see the [Amazon S3 Setup Guide](AmazonS3Setup.md).
|
||||||
|
|
||||||
|
### iCloud Drive (Apple)
|
||||||
|
|
||||||
|
| **Variable** | **Description** |
|
||||||
|
|---------------------------------|-------------------------------------------------------|
|
||||||
|
| `ICLOUD_USERNAME` | Apple ID email address |
|
||||||
|
| `ICLOUD_PASSWORD` | App-specific password (generate at [appleid.apple.com](https://appleid.apple.com/account/manage)) |
|
||||||
|
| `ICLOUD_FOLDER` | Target folder path in iCloud Drive (e.g. `Documents/Uploads`) |
|
||||||
|
| `ICLOUD_COOKIE_DIRECTORY` | Optional directory for session cookie persistence (default: `~/.pyicloud`) |
|
||||||
|
|
||||||
|
> **Note:** Apple does not provide a public REST API for iCloud Drive. This
|
||||||
|
> integration uses the [pyicloud](https://github.com/picklepete/pyicloud)
|
||||||
|
> library which relies on an unofficial, reverse-engineered protocol. Because
|
||||||
|
> most Apple IDs have two-factor authentication enabled, you **must** generate
|
||||||
|
> an [app-specific password](https://support.apple.com/en-us/102654) and use
|
||||||
|
> it as `ICLOUD_PASSWORD`.
|
||||||
|
|
||||||
### Notification System
|
### Notification System
|
||||||
|
|
||||||
| **Variable** | **Description** |
|
| **Variable** | **Description** |
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ in task messages or logs.
|
|||||||
| `PAPERLESS` | Paperless-ngx REST API, API token |
|
| `PAPERLESS` | Paperless-ngx REST API, API token |
|
||||||
| `EMAIL` | SMTP/STARTTLS, file as attachment |
|
| `EMAIL` | SMTP/STARTTLS, file as attachment |
|
||||||
| `RCLONE` | `rclone copyto` subprocess, per-user rclone config |
|
| `RCLONE` | `rclone copyto` subprocess, per-user rclone config |
|
||||||
|
| `ICLOUD` | pyicloud library, Apple ID + app-specific password |
|
||||||
|
|
||||||
### Multiple Destinations
|
### Multiple Destinations
|
||||||
|
|
||||||
|
|||||||
@@ -525,6 +525,7 @@
|
|||||||
<option value="webdav" {% if storage_provider == "webdav" %}selected{% endif %}>WebDAV</option>
|
<option value="webdav" {% if storage_provider == "webdav" %}selected{% endif %}>WebDAV</option>
|
||||||
<option value="ftp" {% if storage_provider == "ftp" %}selected{% endif %}>FTP</option>
|
<option value="ftp" {% if storage_provider == "ftp" %}selected{% endif %}>FTP</option>
|
||||||
<option value="sftp" {% if storage_provider == "sftp" %}selected{% endif %}>SFTP</option>
|
<option value="sftp" {% if storage_provider == "sftp" %}selected{% endif %}>SFTP</option>
|
||||||
|
<option value="icloud" {% if storage_provider == "icloud" %}selected{% endif %}>iCloud Drive</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ boto3>=1.28.0
|
|||||||
# SFTP
|
# SFTP
|
||||||
paramiko>=3.4.0 # SSH/SFTP implementation for Python (LGPL license)
|
paramiko>=3.4.0 # SSH/SFTP implementation for Python (LGPL license)
|
||||||
|
|
||||||
|
# iCloud Drive
|
||||||
|
pyicloud>=2.4.0 # Unofficial Apple iCloud API client (MIT license)
|
||||||
|
|
||||||
# Safe XML parsing (protection against XML bomb / XXE attacks)
|
# Safe XML parsing (protection against XML bomb / XXE attacks)
|
||||||
defusedxml>=0.7.1
|
defusedxml>=0.7.1
|
||||||
|
|
||||||
|
|||||||
@@ -665,6 +665,7 @@ def _all_should_upload_false():
|
|||||||
"email",
|
"email",
|
||||||
"onedrive",
|
"onedrive",
|
||||||
"s3",
|
"s3",
|
||||||
|
"icloud",
|
||||||
]
|
]
|
||||||
return [patch(f"app.tasks.send_to_all._should_upload_to_{s}", return_value=False) for s in services]
|
return [patch(f"app.tasks.send_to_all._should_upload_to_{s}", return_value=False) for s in services]
|
||||||
|
|
||||||
@@ -693,6 +694,7 @@ class TestSendToAllCoverage:
|
|||||||
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
|
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
|
||||||
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
|
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
|
||||||
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
|
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
|
||||||
|
patch("app.tasks.send_to_all._should_upload_to_icloud", return_value=False),
|
||||||
patch("app.tasks.send_to_all.SessionLocal") as mock_session_cls,
|
patch("app.tasks.send_to_all.SessionLocal") as mock_session_cls,
|
||||||
):
|
):
|
||||||
ms.workdir = str(tmp_path)
|
ms.workdir = str(tmp_path)
|
||||||
@@ -804,6 +806,7 @@ class TestSendToAllCoverage:
|
|||||||
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
|
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
|
||||||
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
|
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
|
||||||
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
|
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
|
||||||
|
patch("app.tasks.send_to_all._should_upload_to_icloud", return_value=False),
|
||||||
patch("app.tasks.send_to_all.SessionLocal"),
|
patch("app.tasks.send_to_all.SessionLocal"),
|
||||||
):
|
):
|
||||||
ms.workdir = str(tmp_path)
|
ms.workdir = str(tmp_path)
|
||||||
@@ -863,6 +866,7 @@ class TestSendToAllCoverage:
|
|||||||
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
|
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
|
||||||
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
|
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
|
||||||
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
|
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
|
||||||
|
patch("app.tasks.send_to_all._should_upload_to_icloud", return_value=False),
|
||||||
patch("app.tasks.send_to_all.SessionLocal"),
|
patch("app.tasks.send_to_all.SessionLocal"),
|
||||||
):
|
):
|
||||||
ms.workdir = str(tmp_path)
|
ms.workdir = str(tmp_path)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.tasks.send_to_all import (
|
|||||||
_should_upload_to_email,
|
_should_upload_to_email,
|
||||||
_should_upload_to_ftp,
|
_should_upload_to_ftp,
|
||||||
_should_upload_to_google_drive,
|
_should_upload_to_google_drive,
|
||||||
|
_should_upload_to_icloud,
|
||||||
_should_upload_to_nextcloud,
|
_should_upload_to_nextcloud,
|
||||||
_should_upload_to_onedrive,
|
_should_upload_to_onedrive,
|
||||||
_should_upload_to_paperless,
|
_should_upload_to_paperless,
|
||||||
@@ -145,6 +146,22 @@ class TestShouldUploadFunctions:
|
|||||||
|
|
||||||
assert _should_upload_to_s3() is True
|
assert _should_upload_to_s3() is True
|
||||||
|
|
||||||
|
@patch("app.tasks.send_to_all.settings")
|
||||||
|
def test_should_upload_to_icloud_configured(self, mock_settings):
|
||||||
|
"""Test iCloud upload check."""
|
||||||
|
mock_settings.icloud_username = "user@example.com"
|
||||||
|
mock_settings.icloud_password = "app-specific-password"
|
||||||
|
|
||||||
|
assert _should_upload_to_icloud() is True
|
||||||
|
|
||||||
|
@patch("app.tasks.send_to_all.settings")
|
||||||
|
def test_should_upload_to_icloud_not_configured(self, mock_settings):
|
||||||
|
"""Test iCloud upload check when not configured."""
|
||||||
|
mock_settings.icloud_username = None
|
||||||
|
mock_settings.icloud_password = None
|
||||||
|
|
||||||
|
assert _should_upload_to_icloud() is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestGetConfiguredServicesFromValidator:
|
class TestGetConfiguredServicesFromValidator:
|
||||||
@@ -204,12 +221,14 @@ class TestSendToAllDestinations:
|
|||||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||||
|
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||||
def test_queues_single_configured_service(
|
def test_queues_single_configured_service(
|
||||||
self,
|
self,
|
||||||
mock_upload,
|
mock_upload,
|
||||||
mock_s3,
|
mock_s3,
|
||||||
|
mock_icloud,
|
||||||
mock_onedrive,
|
mock_onedrive,
|
||||||
mock_email,
|
mock_email,
|
||||||
mock_sftp,
|
mock_sftp,
|
||||||
@@ -239,6 +258,7 @@ class TestSendToAllDestinations:
|
|||||||
mock_email.return_value = False
|
mock_email.return_value = False
|
||||||
mock_onedrive.return_value = False
|
mock_onedrive.return_value = False
|
||||||
mock_s3.return_value = False
|
mock_s3.return_value = False
|
||||||
|
mock_icloud.return_value = False
|
||||||
mock_upload.delay.return_value = MagicMock(id="task-123")
|
mock_upload.delay.return_value = MagicMock(id="task-123")
|
||||||
|
|
||||||
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
|
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
|
||||||
@@ -250,6 +270,7 @@ class TestSendToAllDestinations:
|
|||||||
@patch("app.tasks.send_to_all.log_task_progress")
|
@patch("app.tasks.send_to_all.log_task_progress")
|
||||||
@patch("app.tasks.send_to_all.settings")
|
@patch("app.tasks.send_to_all.settings")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_dropbox")
|
@patch("app.tasks.send_to_all._should_upload_to_dropbox")
|
||||||
|
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_nextcloud")
|
@patch("app.tasks.send_to_all._should_upload_to_nextcloud")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_paperless")
|
@patch("app.tasks.send_to_all._should_upload_to_paperless")
|
||||||
@@ -274,6 +295,7 @@ class TestSendToAllDestinations:
|
|||||||
mock_paperless,
|
mock_paperless,
|
||||||
mock_nextcloud,
|
mock_nextcloud,
|
||||||
mock_should_s3,
|
mock_should_s3,
|
||||||
|
mock_icloud,
|
||||||
mock_should_dropbox,
|
mock_should_dropbox,
|
||||||
mock_settings,
|
mock_settings,
|
||||||
mock_log,
|
mock_log,
|
||||||
@@ -295,6 +317,7 @@ class TestSendToAllDestinations:
|
|||||||
mock_sftp.return_value = False
|
mock_sftp.return_value = False
|
||||||
mock_email.return_value = False
|
mock_email.return_value = False
|
||||||
mock_onedrive.return_value = False
|
mock_onedrive.return_value = False
|
||||||
|
mock_icloud.return_value = False
|
||||||
mock_dropbox_upload.delay.return_value = MagicMock(id="dropbox-task")
|
mock_dropbox_upload.delay.return_value = MagicMock(id="dropbox-task")
|
||||||
mock_s3_upload.delay.return_value = MagicMock(id="s3-task")
|
mock_s3_upload.delay.return_value = MagicMock(id="s3-task")
|
||||||
|
|
||||||
@@ -316,10 +339,12 @@ class TestSendToAllDestinations:
|
|||||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||||
|
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||||
def test_skips_unconfigured_services(
|
def test_skips_unconfigured_services(
|
||||||
self,
|
self,
|
||||||
mock_s3,
|
mock_s3,
|
||||||
|
mock_icloud,
|
||||||
mock_onedrive,
|
mock_onedrive,
|
||||||
mock_email,
|
mock_email,
|
||||||
mock_sftp,
|
mock_sftp,
|
||||||
@@ -349,6 +374,7 @@ class TestSendToAllDestinations:
|
|||||||
mock_email.return_value = False
|
mock_email.return_value = False
|
||||||
mock_onedrive.return_value = False
|
mock_onedrive.return_value = False
|
||||||
mock_s3.return_value = False
|
mock_s3.return_value = False
|
||||||
|
mock_icloud.return_value = False
|
||||||
|
|
||||||
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
|
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
|
||||||
|
|
||||||
@@ -369,12 +395,14 @@ class TestSendToAllDestinations:
|
|||||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||||
|
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||||
def test_with_file_id_parameter(
|
def test_with_file_id_parameter(
|
||||||
self,
|
self,
|
||||||
mock_upload,
|
mock_upload,
|
||||||
mock_s3,
|
mock_s3,
|
||||||
|
mock_icloud,
|
||||||
mock_onedrive,
|
mock_onedrive,
|
||||||
mock_email,
|
mock_email,
|
||||||
mock_sftp,
|
mock_sftp,
|
||||||
@@ -404,6 +432,7 @@ class TestSendToAllDestinations:
|
|||||||
mock_email.return_value = False
|
mock_email.return_value = False
|
||||||
mock_onedrive.return_value = False
|
mock_onedrive.return_value = False
|
||||||
mock_s3.return_value = False
|
mock_s3.return_value = False
|
||||||
|
mock_icloud.return_value = False
|
||||||
mock_upload.delay.return_value = MagicMock(id="task-123")
|
mock_upload.delay.return_value = MagicMock(id="task-123")
|
||||||
|
|
||||||
result = send_to_all_destinations.apply(args=[str(test_file), False, 42])
|
result = send_to_all_destinations.apply(args=[str(test_file), False, 42])
|
||||||
@@ -425,6 +454,7 @@ class TestSendToAllDestinations:
|
|||||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||||
|
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||||
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
|
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
|
||||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||||
@@ -433,6 +463,7 @@ class TestSendToAllDestinations:
|
|||||||
mock_upload,
|
mock_upload,
|
||||||
mock_validator,
|
mock_validator,
|
||||||
mock_s3,
|
mock_s3,
|
||||||
|
mock_icloud,
|
||||||
mock_onedrive,
|
mock_onedrive,
|
||||||
mock_email,
|
mock_email,
|
||||||
mock_sftp,
|
mock_sftp,
|
||||||
@@ -463,6 +494,7 @@ class TestSendToAllDestinations:
|
|||||||
mock_email.return_value = False
|
mock_email.return_value = False
|
||||||
mock_onedrive.return_value = False
|
mock_onedrive.return_value = False
|
||||||
mock_s3.return_value = False
|
mock_s3.return_value = False
|
||||||
|
mock_icloud.return_value = False
|
||||||
mock_upload.delay.return_value = MagicMock(id="task-123")
|
mock_upload.delay.return_value = MagicMock(id="task-123")
|
||||||
|
|
||||||
result = send_to_all_destinations.apply(args=[str(test_file), True, 1])
|
result = send_to_all_destinations.apply(args=[str(test_file), True, 1])
|
||||||
@@ -482,12 +514,14 @@ class TestSendToAllDestinations:
|
|||||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||||
|
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||||
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
|
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
|
||||||
def test_validator_exception_fallback(
|
def test_validator_exception_fallback(
|
||||||
self,
|
self,
|
||||||
mock_validator,
|
mock_validator,
|
||||||
mock_s3,
|
mock_s3,
|
||||||
|
mock_icloud,
|
||||||
mock_onedrive,
|
mock_onedrive,
|
||||||
mock_email,
|
mock_email,
|
||||||
mock_sftp,
|
mock_sftp,
|
||||||
@@ -518,6 +552,7 @@ class TestSendToAllDestinations:
|
|||||||
mock_email.return_value = False
|
mock_email.return_value = False
|
||||||
mock_onedrive.return_value = False
|
mock_onedrive.return_value = False
|
||||||
mock_s3.return_value = False
|
mock_s3.return_value = False
|
||||||
|
mock_icloud.return_value = False
|
||||||
|
|
||||||
# Should not raise, should fall back to individual checks
|
# Should not raise, should fall back to individual checks
|
||||||
result = send_to_all_destinations.apply(args=[str(test_file), True, 1])
|
result = send_to_all_destinations.apply(args=[str(test_file), True, 1])
|
||||||
@@ -536,12 +571,14 @@ class TestSendToAllDestinations:
|
|||||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||||
|
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||||
def test_handles_upload_task_queue_error(
|
def test_handles_upload_task_queue_error(
|
||||||
self,
|
self,
|
||||||
mock_upload,
|
mock_upload,
|
||||||
mock_s3,
|
mock_s3,
|
||||||
|
mock_icloud,
|
||||||
mock_onedrive,
|
mock_onedrive,
|
||||||
mock_email,
|
mock_email,
|
||||||
mock_sftp,
|
mock_sftp,
|
||||||
@@ -571,6 +608,7 @@ class TestSendToAllDestinations:
|
|||||||
mock_email.return_value = False
|
mock_email.return_value = False
|
||||||
mock_onedrive.return_value = False
|
mock_onedrive.return_value = False
|
||||||
mock_s3.return_value = False
|
mock_s3.return_value = False
|
||||||
|
mock_icloud.return_value = False
|
||||||
mock_upload.delay.side_effect = Exception("Queue error")
|
mock_upload.delay.side_effect = Exception("Queue error")
|
||||||
|
|
||||||
# Should not raise, should log error
|
# Should not raise, should log error
|
||||||
@@ -580,6 +618,7 @@ class TestSendToAllDestinations:
|
|||||||
# Error should be recorded in results
|
# Error should be recorded in results
|
||||||
assert "dropbox_error" in result.result["tasks"]
|
assert "dropbox_error" in result.result["tasks"]
|
||||||
|
|
||||||
|
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||||
@@ -608,6 +647,7 @@ class TestSendToAllDestinations:
|
|||||||
mock_email,
|
mock_email,
|
||||||
mock_onedrive,
|
mock_onedrive,
|
||||||
mock_s3,
|
mock_s3,
|
||||||
|
mock_icloud,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
):
|
):
|
||||||
"""Test file_id lookup fallback when not provided."""
|
"""Test file_id lookup fallback when not provided."""
|
||||||
@@ -629,6 +669,7 @@ class TestSendToAllDestinations:
|
|||||||
mock_email.return_value = False
|
mock_email.return_value = False
|
||||||
mock_onedrive.return_value = False
|
mock_onedrive.return_value = False
|
||||||
mock_s3.return_value = False
|
mock_s3.return_value = False
|
||||||
|
mock_icloud.return_value = False
|
||||||
|
|
||||||
# Mock database session
|
# Mock database session
|
||||||
mock_db = MagicMock()
|
mock_db = MagicMock()
|
||||||
@@ -656,10 +697,12 @@ class TestSendToAllDestinations:
|
|||||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||||
|
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||||
def test_should_upload_check_exception_handling(
|
def test_should_upload_check_exception_handling(
|
||||||
self,
|
self,
|
||||||
mock_s3,
|
mock_s3,
|
||||||
|
mock_icloud,
|
||||||
mock_onedrive,
|
mock_onedrive,
|
||||||
mock_email,
|
mock_email,
|
||||||
mock_sftp,
|
mock_sftp,
|
||||||
@@ -689,6 +732,7 @@ class TestSendToAllDestinations:
|
|||||||
mock_email.return_value = False
|
mock_email.return_value = False
|
||||||
mock_onedrive.return_value = False
|
mock_onedrive.return_value = False
|
||||||
mock_s3.return_value = False
|
mock_s3.return_value = False
|
||||||
|
mock_icloud.return_value = False
|
||||||
|
|
||||||
# Should not raise, should treat as not configured
|
# Should not raise, should treat as not configured
|
||||||
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
|
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Unit tests for the iCloud Drive upload task and helper functions.
|
||||||
|
|
||||||
|
Tests cover the global upload task (``upload_to_icloud``) as well as the
|
||||||
|
per-user integration handler (``_upload_icloud`` in
|
||||||
|
``upload_to_user_integration``). All external calls to ``pyicloud`` are
|
||||||
|
mocked so tests are fast, hermetic, and free of network access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TASK_ID = "test-icloud-task-id"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_file(path, content: bytes = b"PDF content") -> None:
|
||||||
|
"""Write *content* to *path*, creating parent dirs as needed."""
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "wb") as fh:
|
||||||
|
fh.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_pyicloud_module(mock_api):
|
||||||
|
"""Return a mock ``pyicloud`` module whose ``PyiCloudService`` returns *mock_api*."""
|
||||||
|
mock_mod = MagicMock()
|
||||||
|
mock_mod.PyiCloudService.return_value = mock_api
|
||||||
|
return mock_mod
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _get_icloud_api
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGetIcloudApi:
|
||||||
|
"""Tests for the _get_icloud_api helper."""
|
||||||
|
|
||||||
|
def test_returns_authenticated_api(self):
|
||||||
|
mock_api = MagicMock()
|
||||||
|
mock_api.requires_2sa = False
|
||||||
|
mock_api.requires_2fa = False
|
||||||
|
|
||||||
|
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
|
||||||
|
from app.tasks.upload_to_icloud import _get_icloud_api
|
||||||
|
|
||||||
|
result = _get_icloud_api("user@example.com", "secret")
|
||||||
|
|
||||||
|
assert result is mock_api
|
||||||
|
|
||||||
|
def test_passes_cookie_directory(self):
|
||||||
|
mock_api = MagicMock()
|
||||||
|
mock_api.requires_2sa = False
|
||||||
|
mock_api.requires_2fa = False
|
||||||
|
mock_mod = _mock_pyicloud_module(mock_api)
|
||||||
|
|
||||||
|
with patch.dict("sys.modules", {"pyicloud": mock_mod}):
|
||||||
|
from app.tasks.upload_to_icloud import _get_icloud_api
|
||||||
|
|
||||||
|
_get_icloud_api("user@example.com", "secret", "/tmp/cookies")
|
||||||
|
|
||||||
|
mock_mod.PyiCloudService.assert_called_once_with("user@example.com", "secret", cookie_directory="/tmp/cookies")
|
||||||
|
|
||||||
|
def test_raises_on_2fa_required(self):
|
||||||
|
mock_api = MagicMock()
|
||||||
|
mock_api.requires_2sa = False
|
||||||
|
mock_api.requires_2fa = True
|
||||||
|
|
||||||
|
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
|
||||||
|
from app.tasks.upload_to_icloud import _get_icloud_api
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="two-factor authentication"):
|
||||||
|
_get_icloud_api("user@example.com", "secret")
|
||||||
|
|
||||||
|
def test_raises_on_2sa_required(self):
|
||||||
|
mock_api = MagicMock()
|
||||||
|
mock_api.requires_2sa = True
|
||||||
|
mock_api.requires_2fa = False
|
||||||
|
|
||||||
|
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
|
||||||
|
from app.tasks.upload_to_icloud import _get_icloud_api
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="two-factor authentication"):
|
||||||
|
_get_icloud_api("user@example.com", "secret")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _navigate_to_folder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestNavigateToFolder:
|
||||||
|
"""Tests for the _navigate_to_folder helper."""
|
||||||
|
|
||||||
|
def test_empty_path_returns_root(self):
|
||||||
|
from app.tasks.upload_to_icloud import _navigate_to_folder
|
||||||
|
|
||||||
|
root = MagicMock()
|
||||||
|
result = _navigate_to_folder(root, "")
|
||||||
|
assert result is root
|
||||||
|
|
||||||
|
def test_navigates_existing_folders(self):
|
||||||
|
from app.tasks.upload_to_icloud import _navigate_to_folder
|
||||||
|
|
||||||
|
# Build a mock folder tree: root -> Documents -> Uploads
|
||||||
|
uploads_node = MagicMock()
|
||||||
|
uploads_node.name = "Uploads"
|
||||||
|
|
||||||
|
docs_node = MagicMock()
|
||||||
|
docs_node.name = "Documents"
|
||||||
|
docs_node.dir.return_value = [uploads_node]
|
||||||
|
|
||||||
|
root = MagicMock()
|
||||||
|
root.dir.return_value = [docs_node]
|
||||||
|
|
||||||
|
result = _navigate_to_folder(root, "Documents/Uploads")
|
||||||
|
assert result is uploads_node
|
||||||
|
|
||||||
|
def test_creates_missing_folder(self):
|
||||||
|
from app.tasks.upload_to_icloud import _navigate_to_folder
|
||||||
|
|
||||||
|
new_folder = MagicMock()
|
||||||
|
root = MagicMock()
|
||||||
|
root.dir.return_value = [] # No children
|
||||||
|
root.mkdir.return_value = new_folder
|
||||||
|
|
||||||
|
result = _navigate_to_folder(root, "NewFolder")
|
||||||
|
root.mkdir.assert_called_once_with("NewFolder")
|
||||||
|
assert result is new_folder
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _upload_icloud (user integration handler)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestUploadIcloudHandler:
|
||||||
|
"""Tests for _upload_icloud handler in upload_to_user_integration."""
|
||||||
|
|
||||||
|
def _call(self, file_path: str, cfg: dict, creds: dict) -> dict:
|
||||||
|
from app.tasks.upload_to_user_integration import _upload_icloud
|
||||||
|
|
||||||
|
return _upload_icloud(file_path, cfg, creds, TASK_ID)
|
||||||
|
|
||||||
|
def test_raises_when_credentials_missing(self, tmp_path):
|
||||||
|
fp = str(tmp_path / "doc.pdf")
|
||||||
|
_write_file(fp)
|
||||||
|
with pytest.raises(ValueError, match="username or password"):
|
||||||
|
self._call(fp, {}, {})
|
||||||
|
|
||||||
|
def test_raises_when_password_missing(self, tmp_path):
|
||||||
|
fp = str(tmp_path / "doc.pdf")
|
||||||
|
_write_file(fp)
|
||||||
|
with pytest.raises(ValueError, match="username or password"):
|
||||||
|
self._call(fp, {}, {"username": "user@example.com"})
|
||||||
|
|
||||||
|
def test_successful_upload(self, tmp_path):
|
||||||
|
fp = str(tmp_path / "doc.pdf")
|
||||||
|
_write_file(fp)
|
||||||
|
|
||||||
|
mock_api = MagicMock()
|
||||||
|
mock_api.requires_2sa = False
|
||||||
|
mock_api.requires_2fa = False
|
||||||
|
|
||||||
|
# drive.dir() returns nothing -> mkdir will be called
|
||||||
|
mock_folder = MagicMock()
|
||||||
|
mock_api.drive.dir.return_value = []
|
||||||
|
mock_api.drive.mkdir.return_value = mock_folder
|
||||||
|
|
||||||
|
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
|
||||||
|
result = self._call(
|
||||||
|
fp,
|
||||||
|
{"folder": "Documents"},
|
||||||
|
{"username": "user@example.com", "password": "secret"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "Completed"
|
||||||
|
assert result["icloud_folder"] == "Documents"
|
||||||
|
mock_folder.upload.assert_called_once()
|
||||||
|
|
||||||
|
def test_upload_to_root_when_no_folder(self, tmp_path):
|
||||||
|
fp = str(tmp_path / "doc.pdf")
|
||||||
|
_write_file(fp)
|
||||||
|
|
||||||
|
mock_api = MagicMock()
|
||||||
|
mock_api.requires_2sa = False
|
||||||
|
mock_api.requires_2fa = False
|
||||||
|
|
||||||
|
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
|
||||||
|
result = self._call(
|
||||||
|
fp,
|
||||||
|
{},
|
||||||
|
{"username": "user@example.com", "password": "secret"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "Completed"
|
||||||
|
assert result["icloud_folder"] == "/"
|
||||||
|
mock_api.drive.upload.assert_called_once()
|
||||||
@@ -82,6 +82,8 @@ class TestGetConfiguredDestinations:
|
|||||||
cfg.sftp_username = None
|
cfg.sftp_username = None
|
||||||
cfg.ftp_host = None
|
cfg.ftp_host = None
|
||||||
cfg.ftp_username = None
|
cfg.ftp_username = None
|
||||||
|
cfg.icloud_username = None
|
||||||
|
cfg.icloud_password = None
|
||||||
|
|
||||||
result = _get_configured_destinations(cfg)
|
result = _get_configured_destinations(cfg)
|
||||||
assert result == []
|
assert result == []
|
||||||
@@ -107,6 +109,8 @@ class TestGetConfiguredDestinations:
|
|||||||
cfg.sftp_username = None
|
cfg.sftp_username = None
|
||||||
cfg.ftp_host = None
|
cfg.ftp_host = None
|
||||||
cfg.ftp_username = None
|
cfg.ftp_username = None
|
||||||
|
cfg.icloud_username = None
|
||||||
|
cfg.icloud_password = None
|
||||||
|
|
||||||
result = _get_configured_destinations(cfg)
|
result = _get_configured_destinations(cfg)
|
||||||
ids = [d["id"] for d in result]
|
ids = [d["id"] for d in result]
|
||||||
@@ -133,6 +137,8 @@ class TestGetConfiguredDestinations:
|
|||||||
cfg.sftp_username = None
|
cfg.sftp_username = None
|
||||||
cfg.ftp_host = None
|
cfg.ftp_host = None
|
||||||
cfg.ftp_username = None
|
cfg.ftp_username = None
|
||||||
|
cfg.icloud_username = None
|
||||||
|
cfg.icloud_password = None
|
||||||
|
|
||||||
result = _get_configured_destinations(cfg)
|
result = _get_configured_destinations(cfg)
|
||||||
ids = [d["id"] for d in result]
|
ids = [d["id"] for d in result]
|
||||||
@@ -159,6 +165,8 @@ class TestGetConfiguredDestinations:
|
|||||||
cfg.sftp_username = None
|
cfg.sftp_username = None
|
||||||
cfg.ftp_host = None
|
cfg.ftp_host = None
|
||||||
cfg.ftp_username = None
|
cfg.ftp_username = None
|
||||||
|
cfg.icloud_username = None
|
||||||
|
cfg.icloud_password = None
|
||||||
|
|
||||||
result = _get_configured_destinations(cfg)
|
result = _get_configured_destinations(cfg)
|
||||||
ids = [d["id"] for d in result]
|
ids = [d["id"] for d in result]
|
||||||
@@ -185,6 +193,8 @@ class TestGetConfiguredDestinations:
|
|||||||
cfg.sftp_username = None
|
cfg.sftp_username = None
|
||||||
cfg.ftp_host = None
|
cfg.ftp_host = None
|
||||||
cfg.ftp_username = None
|
cfg.ftp_username = None
|
||||||
|
cfg.icloud_username = None
|
||||||
|
cfg.icloud_password = None
|
||||||
|
|
||||||
result = _get_configured_destinations(cfg)
|
result = _get_configured_destinations(cfg)
|
||||||
ids = [d["id"] for d in result]
|
ids = [d["id"] for d in result]
|
||||||
@@ -211,6 +221,8 @@ class TestGetConfiguredDestinations:
|
|||||||
cfg.sftp_username = None
|
cfg.sftp_username = None
|
||||||
cfg.ftp_host = None
|
cfg.ftp_host = None
|
||||||
cfg.ftp_username = None
|
cfg.ftp_username = None
|
||||||
|
cfg.icloud_username = None
|
||||||
|
cfg.icloud_password = None
|
||||||
|
|
||||||
result = _get_configured_destinations(cfg)
|
result = _get_configured_destinations(cfg)
|
||||||
ids = [d["id"] for d in result]
|
ids = [d["id"] for d in result]
|
||||||
@@ -237,6 +249,8 @@ class TestGetConfiguredDestinations:
|
|||||||
cfg.sftp_username = None
|
cfg.sftp_username = None
|
||||||
cfg.ftp_host = None
|
cfg.ftp_host = None
|
||||||
cfg.ftp_username = None
|
cfg.ftp_username = None
|
||||||
|
cfg.icloud_username = None
|
||||||
|
cfg.icloud_password = None
|
||||||
|
|
||||||
result = _get_configured_destinations(cfg)
|
result = _get_configured_destinations(cfg)
|
||||||
ids = [d["id"] for d in result]
|
ids = [d["id"] for d in result]
|
||||||
@@ -263,6 +277,8 @@ class TestGetConfiguredDestinations:
|
|||||||
cfg.sftp_username = None
|
cfg.sftp_username = None
|
||||||
cfg.ftp_host = None
|
cfg.ftp_host = None
|
||||||
cfg.ftp_username = None
|
cfg.ftp_username = None
|
||||||
|
cfg.icloud_username = None
|
||||||
|
cfg.icloud_password = None
|
||||||
|
|
||||||
result = _get_configured_destinations(cfg)
|
result = _get_configured_destinations(cfg)
|
||||||
ids = [d["id"] for d in result]
|
ids = [d["id"] for d in result]
|
||||||
@@ -289,6 +305,8 @@ class TestGetConfiguredDestinations:
|
|||||||
cfg.sftp_username = "user"
|
cfg.sftp_username = "user"
|
||||||
cfg.ftp_host = None
|
cfg.ftp_host = None
|
||||||
cfg.ftp_username = None
|
cfg.ftp_username = None
|
||||||
|
cfg.icloud_username = None
|
||||||
|
cfg.icloud_password = None
|
||||||
|
|
||||||
result = _get_configured_destinations(cfg)
|
result = _get_configured_destinations(cfg)
|
||||||
ids = [d["id"] for d in result]
|
ids = [d["id"] for d in result]
|
||||||
@@ -315,6 +333,8 @@ class TestGetConfiguredDestinations:
|
|||||||
cfg.sftp_username = None
|
cfg.sftp_username = None
|
||||||
cfg.ftp_host = "ftp.example.com"
|
cfg.ftp_host = "ftp.example.com"
|
||||||
cfg.ftp_username = "user"
|
cfg.ftp_username = "user"
|
||||||
|
cfg.icloud_username = None
|
||||||
|
cfg.icloud_password = None
|
||||||
|
|
||||||
result = _get_configured_destinations(cfg)
|
result = _get_configured_destinations(cfg)
|
||||||
ids = [d["id"] for d in result]
|
ids = [d["id"] for d in result]
|
||||||
@@ -341,6 +361,8 @@ class TestGetConfiguredDestinations:
|
|||||||
cfg.sftp_username = "sftpuser"
|
cfg.sftp_username = "sftpuser"
|
||||||
cfg.ftp_host = "ftp.example.com"
|
cfg.ftp_host = "ftp.example.com"
|
||||||
cfg.ftp_username = "ftpuser"
|
cfg.ftp_username = "ftpuser"
|
||||||
|
cfg.icloud_username = "user@example.com"
|
||||||
|
cfg.icloud_password = "app-pass"
|
||||||
|
|
||||||
result = _get_configured_destinations(cfg)
|
result = _get_configured_destinations(cfg)
|
||||||
assert len(result) == len(_DESTINATION_META)
|
assert len(result) == len(_DESTINATION_META)
|
||||||
@@ -366,6 +388,8 @@ class TestGetConfiguredDestinations:
|
|||||||
cfg.sftp_username = None
|
cfg.sftp_username = None
|
||||||
cfg.ftp_host = None
|
cfg.ftp_host = None
|
||||||
cfg.ftp_username = None
|
cfg.ftp_username = None
|
||||||
|
cfg.icloud_username = None
|
||||||
|
cfg.icloud_password = None
|
||||||
|
|
||||||
result = _get_configured_destinations(cfg)
|
result = _get_configured_destinations(cfg)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
@@ -393,6 +417,8 @@ class TestGetConfiguredDestinations:
|
|||||||
cfg.sftp_username = None
|
cfg.sftp_username = None
|
||||||
cfg.ftp_host = None
|
cfg.ftp_host = None
|
||||||
cfg.ftp_username = None
|
cfg.ftp_username = None
|
||||||
|
cfg.icloud_username = None
|
||||||
|
cfg.icloud_password = None
|
||||||
|
|
||||||
result = _get_configured_destinations(cfg)
|
result = _get_configured_destinations(cfg)
|
||||||
ids = [d["id"] for d in result]
|
ids = [d["id"] for d in result]
|
||||||
|
|||||||
Reference in New Issue
Block a user