diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 571ba16..cb21c1e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,41 +4,86 @@ permissions:
on:
push:
- branches: [ "main" ]
+ branches: [main]
pull_request:
- branches: [ "main" ]
+ branches: [main]
env:
IMAGE_NAME: ${{ github.repository }}
+ DOCKER_HUB_IMAGE_NAME: mvflc/backvault
jobs:
- build:
+ lint:
+ name: Lint
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - name: Install system dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y gcc libsqlite3-dev libsqlcipher-dev libssl-dev
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: '3.13'
- - name: Install uv
- run: pip install uv
- - name: Install dependencies
- run: uv sync --dev
- - name: Lint with ruff
- run: |
- uv run ruff check
- uv run ruff format
- - name: Test with pytest
- run: |
- uv run pytest
-
- docker-image-test:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.13'
+
+ - name: Install system dependencies
+ run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev libsqlcipher-dev libssl-dev
+
+ - name: Install uv
+ run: pip install uv
+
+ - name: Install dependencies
+ run: uv sync --dev
+
+ - name: Run ruff check
+ run: uv run ruff check
+
+ - name: Run ruff format check
+ run: uv run ruff format --check
+
+ unit-tests:
+ name: Unit Tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y gcc libsqlite3-dev libsqlcipher-dev libssl-dev
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.13'
+
+ - name: Install uv
+ run: pip install uv
+
+ - name: Install dependencies
+ run: uv sync --dev
+
+ - name: Run pytest
+ run: uv run pytest -v --cov=src --cov-report=xml --cov-report=term
+
+ - name: Upload coverage
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: coverage
+ path: coverage.xml
+ retention-days: 7
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v5
+ with:
+ files: ./coverage.xml
+ flags: unittests
+ name: codecov-umbrella
+ fail_ci_if_error: false
+
+ docker-build:
+ name: Docker Build (${{ matrix.platform }})
runs-on: ubuntu-latest
strategy:
+ fail-fast: false
matrix:
platform:
- linux/amd64
@@ -46,18 +91,105 @@ jobs:
- linux/arm/v7
steps:
- uses: actions/checkout@v4
- - name: Sanitize ref name for docker tag
- id: sanitize_ref
- run: echo "ref_name=$(echo ${{ github.ref_name }} | sed 's/\//-/g')" >> $GITHUB_OUTPUT
+
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
+
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
+
+ - name: Set platform slug
+ id: platform-slug
+ run: |
+ PLATFORM="${{ matrix.platform }}"
+ echo "slug=${PLATFORM//\//-}" >> "$GITHUB_OUTPUT"
+
- name: Build Docker image
+ uses: docker/build-push-action@v5
+ with:
+ platforms: ${{ matrix.platform }}
+ load: true
+ tags: |
+ ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Verify Bitwarden CLI
+ run: |
+ docker run --rm --platform ${{ matrix.platform }} \
+ ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \
+ bw --version
+
+ - name: Verify supercronic
+ run: |
+ docker run --rm --platform ${{ matrix.platform }} \
+ ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \
+ supercronic --version
+
+ - name: Verify Python
+ run: |
+ docker run --rm --platform ${{ matrix.platform }} \
+ ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \
+ python3 --version
+
+ - name: Verify entrypoint is executable
+ run: |
+ docker run --rm --platform ${{ matrix.platform }} \
+ ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \
+ test -x /app/entrypoint.sh
+
+ - name: Verify run script is executable
run: |
- docker buildx build --platform=${{ matrix.platform }} --load \
- -t ${{ env.IMAGE_NAME }}:${{ steps.sanitize_ref.outputs.ref_name }}-test \
- .
- - name: Run test
+ docker run --rm --platform ${{ matrix.platform }} \
+ ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \
+ test -x /app/run.sh
+
+ - name: Verify required directories exist
run: |
- docker run --rm --platform ${{ matrix.platform }} ${{ env.IMAGE_NAME }}:${{ steps.sanitize_ref.outputs.ref_name }}-test bw --version
\ No newline at end of file
+ docker run --rm --platform ${{ matrix.platform }} \
+ ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \
+ sh -c 'test -d /app/backups && test -d /app/db && test -d /app/logs'
+
+ docker-push:
+ name: Docker Push
+ needs: [docker-build, lint, unit-tests]
+ runs-on: ubuntu-latest
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKER_HUB_USERNAME }}
+ password: ${{ secrets.DOCKER_HUB_TOKEN }}
+
+ - name: Build and push multi-arch image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ platforms: linux/amd64,linux/arm64,linux/arm/v7
+ push: true
+ tags: |
+ ghcr.io/${{ env.IMAGE_NAME }}:latest
+ ghcr.io/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
+ ${{ env.DOCKER_HUB_IMAGE_NAME }}:latest
+ ${{ env.DOCKER_HUB_IMAGE_NAME }}:sha-${{ github.sha }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
\ No newline at end of file
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 0000000..572d061
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,125 @@
+name: E2E Tests
+permissions:
+ contents: read
+ actions: read
+
+on:
+ workflow_run:
+ workflows: [CI]
+ types: [completed]
+ branches: [main]
+ pull_request:
+ paths:
+ - 'tests/**'
+ - '.github/workflows/e2e.yml'
+ - 'src/**'
+ workflow_dispatch:
+
+env:
+ IMAGE_NAME: docker.io/mvflc/backvault
+ VAULTWARDEN_PORT: '8888'
+ VAULTWARDEN_URL: http://localhost:8888
+
+jobs:
+ e2e:
+ name: E2E Tests
+ if: >
+ (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') ||
+ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
+ github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ services:
+ vaultwarden:
+ image: vaultwarden/server:latest
+ env:
+ SIGNUPS_ALLOWED: "true"
+ ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }}
+ I_REALLY_WANT_VOLATILE_STORAGE: "true"
+ ports:
+ - 8888:80
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Cleanup appgroup
+ run: |
+ sudo delgroup appgroup 2>/dev/null || true
+ sudo deluser appuser 2>/dev/null || true
+
+ - name: Validate ADMIN_TOKEN
+ env:
+ ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }}
+ run: |
+ if [ -z "$ADMIN_TOKEN" ]; then
+ echo "Error: ADMIN_TOKEN secret is required"
+ exit 1
+ fi
+
+ - name: Pull test image
+ run: |
+ docker pull docker.io/mvflc/backvault:test
+ docker tag docker.io/mvflc/backvault:test ${{ env.IMAGE_NAME }}:test
+
+ - name: Wait for Vaultwarden
+ run: |
+ for i in $(seq 1 30); do
+ HTTP_CODE=$(curl -so /dev/null -w '%{http_code}' -L "http://localhost:8888/api/config" 2>/dev/null)
+ if [ "$HTTP_CODE" = "200" ]; then
+ echo "Vaultwarden is ready (HTTP $HTTP_CODE)"
+ exit 0
+ fi
+ echo "Attempt $i/30: Waiting... (HTTP $HTTP_CODE)"
+ sleep 2
+ done
+ echo "Error: Vaultwarden failed to start"
+ exit 1
+
+ - name: Verify Vaultwarden is running
+ run: |
+ HTTP_CODE=$(curl -so /dev/null -w '%{http_code}' -L "http://localhost:8888/api/config" 2>/dev/null)
+ if [ "$HTTP_CODE" != "200" ]; then
+ echo "Error: Vaultwarden health check failed (HTTP $HTTP_CODE)"
+ exit 1
+ fi
+ echo "Vaultwarden health check passed"
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.13'
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install system dependencies
+ run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev libsqlcipher-dev libssl-dev
+
+ - name: Install Bitwarden CLI
+ run: |
+ npm install -g @bitwarden/cli
+
+ - name: Install dependencies
+ run: |
+ pip install uv
+ uv sync --dev
+
+ - name: Run E2E tests
+ env:
+ VAULTWARDEN_URL: ${{ env.VAULTWARDEN_URL }}
+ BW_TEST_EMAIL: ${{ secrets.BW_TEST_EMAIL }}
+ BW_TEST_PASSWORD: ${{ secrets.BW_TEST_PASSWORD }}
+ BW_TEST_MASTER_PASSWORD: ${{ secrets.BW_TEST_MASTER_PASSWORD }}
+ IMAGE_NAME: ${{ env.IMAGE_NAME }}:test
+ run: uv run pytest tests/test_e2e.py -v -m e2e -o "addopts="
+
+ - name: Cleanup Docker buildx
+ if: always()
+ run: |
+ docker buildx prune --all -f 2>/dev/null || true
\ No newline at end of file
diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml
new file mode 100644
index 0000000..0abcbb6
--- /dev/null
+++ b/.github/workflows/opencode.yml
@@ -0,0 +1,46 @@
+name: opencode
+
+on:
+ issue_comment:
+ types: [created]
+ pull_request_review_comment:
+ types: [created]
+
+jobs:
+ opencode:
+ if: >
+ (github.event.comment.author_association == 'OWNER' ||
+ github.event.comment.author_association == 'MEMBER' ||
+ github.event.comment.author_association == 'COLLABORATOR')
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: write
+ pull-requests: write
+ issues: read
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Check for command
+ id: check-command
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const body = context.payload.comment.body;
+ const isCommand = /^\/(?:oc|opencode)(?:\s|$)/m.test(body);
+ core.setOutput('is_command', isCommand);
+
+ - name: Install dependencies
+ if: steps.check-command.outputs.is_command == 'true'
+ run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev libsqlcipher-dev libssl-dev
+
+ - name: Run opencode
+ if: steps.check-command.outputs.is_command == 'true'
+ uses: anomalyco/opencode/github@v1.14.19
+ env:
+ OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
+ with:
+ model: opencode/big-pickle
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index a9de155..99a298a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
__pycache__/
.ruff_cache/
.venv/
+bin/
backup/
logs/
decrypt.py
@@ -11,3 +12,8 @@ db/
trivy
*bandit*
.pre-commit*
+.secrets
+.coverage
+coverage.xml
+FLOW.md
+STATE.md
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..09b06f4
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,44 @@
+# AGENTS.md
+
+## Dev Commands
+
+```bash
+# Install dependencies (uses uv, not pip)
+uv sync --dev
+
+# Lint and format
+uv run ruff check
+uv run ruff format
+
+# Run tests
+uv run pytest
+```
+
+## Architecture
+
+- **Python 3.12+** with `uv` for package management
+- **SQLCipher** encrypted SQLite database for credential storage
+- **FastAPI** serves setup UI on first run (src/init.py)
+- **src/run.py** is the main backup execution logic
+- **src/bw_client.py** wraps Bitwarden CLI for vault export
+
+## Key Constraints
+
+- Requires system libs: `libsqlite3-dev libsqlcipher-dev libssl-dev` (see CI workflow)
+- Two encryption modes: `bitwarden` (default, requires CLI to decrypt) and `raw` (portable AES-256-GCM)
+- Database path: `/app/db/backvault.db` with pragma key at `/app/db/backvault.db.pragma`
+- Backup dir: `/app/backups` (or `/tmp` when `TEST_MODE` is set)
+
+## Multi-Organization Export
+
+- Configured via setup UI: organization IDs (comma-separated) and export mode (`single` or `multiple`)
+- `single`: All orgs merged into one file (`backup_{timestamp}_orgs.enc`)
+- `multiple`: Each org exported separately (`backup_{timestamp}_org-{org-id}.enc`)
+- Personal vault always exported separately from organizations
+
+## Docker
+
+Multi-arch builds (amd64, arm64, arm/v7) use QEMU. Build with:
+```bash
+docker buildx build --platform=linux/amd64,linux/arm64,linux/arm/v7 --load .
+```
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 8cac6a5..135f6ea 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -28,7 +28,13 @@ RUN apk update && apk add --no-cache \
RUN apk upgrade -a
-RUN addgroup -S appgroup && adduser -S appuser -G appgroup
+# Create appgroup and appuser idempotently (GID/UID 1000 to match default PGID/PUID)
+RUN if ! getent group appgroup > /dev/null 2>&1; then \
+ addgroup -S -g 1000 appgroup; \
+ fi && \
+ if ! getent passwd appuser > /dev/null 2>&1; then \
+ adduser -S -u 1000 -G appgroup appuser; \
+ fi
# Install Bitwarden CLI
RUN set -eux; \
@@ -82,3 +88,4 @@ ENV PYTHONPATH=/app
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["/app/run.sh"]
+
diff --git a/README.md b/README.md
index 80dcc1f..ab25664 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ Itβs designed for hands-free, secure, and automated backups using the official
* β¨ **Two Encryption Modes**: Choose between Bitwarden's native encrypted format or a portable, standard AES-256-GCM encrypted format.
* π Works with both Bitwarden Cloud and self-hosted Bitwarden/Vaultwarden
* π³ Runs fully containerized β no setup or local dependencies required
+* π’ **Multi-Organization Support**: Export multiple organizations with flexible output options
---
@@ -90,6 +91,36 @@ Together, these changes make BackVault one of the most secure self-hosted Bitwar
---
+## π’ Multi-Organization Export
+
+BackVault supports backing up **multiple organizations** from your Bitwarden account. During setup, you can configure:
+
+1. **Organization IDs**: Comma-separated list of organization IDs to export. Leave empty to export all accessible organizations.
+2. **Export Mode**: Choose how organizations are exported:
+ - **Single combined file**: All orgs merged into one encrypted file (`backup_{timestamp}_orgs.enc`)
+ - **Separate files per organization**: Each org exported to its own file (`backup_{timestamp}_org-{org-id}.enc`)
+
+The personal vault is always exported separately (as `backup_{timestamp}.enc` or `backup_{timestamp}_personal.enc`).
+
+### Encryption Mode Compatibility
+
+**Important:** There is a compatibility requirement between encryption mode and organization export mode:
+
+- **`raw` encryption mode**: Supports both Single and Separate files export options.
+- **`bitwarden` encryption mode**: Only supports Separate files per organization. The Single combined option is **not supported** because Bitwarden CLI cannot produce a single merged org export.
+
+When using the default `bitwarden` encryption mode, select **"Separate files per organization"** in the setup UI. The Single combined option is only available when `BACKUP_ENCRYPTION_MODE=raw` is set in your environment.
+
+### Getting Organization IDs
+
+Run this command after logging in to find your organization IDs:
+
+```bash
+bw list organizations
+```
+
+---
+
## π§© Docker Compose Example
Hereβs how to set it up with Docker Compose for easy management:
@@ -137,12 +168,15 @@ BackVault will automatically:
| ------------------------------ | ---------------------------------------------- | -------- | --------------------------- |
| `BW_SERVER` | Bitwarden or Vaultwarden server URL | β
| `https://vault.example.com` |
| `BACKUP_INTERVAL_HOURS` | Alternative to cron expression (integer hours) | β | `12` |
-| `BACKUP_ENCRYPTION_MODE` | `bitwarden` (default) or `raw` for portable AES-256-GCM encryption. | β | `raw` |
+| `BACKUP_ENCRYPTION_MODE` | `bitwarden` (default) or `raw` for portable AES-256-GCM encryption. **Note:** `raw` is required for Single org export mode. | β | `bitwarden` |
| `RETAIN_DAYS` | Days to keep backups. `7` by default. Set to `0` to disable cleanup. | β | `7` |
| `CRON_EXPRESSION` | Cron string to schedule backups | β | `0 */12 * * *` |
| `NODE_TLS_REJECT_UNAUTHORIZED` | Set to `0` for self-signed certs | β | `0` |
-| `TZ` | Timezone for the container according to https://en.wikipedia.org/wiki/List_of_tz_database_time_zones | β | `UTC` |
-| `PUID` |
+| `TZ` | Timezone for the container according to https://en.wikipedia.org/wiki/List_of_tz_database_time_zones | β | `UTC` |
+| `PUID` | UID of the user to run as (non-root container) | β | `1000` |
+| `PGID` | GID of the user to run as (non-root container) | β | `1000` |
+
+> **Note:** Organization IDs and export mode are configured via the web setup UI, not environment variables. Use "Separate files per organization" when `BACKUP_ENCRYPTION_MODE=bitwarden` (default).
---
diff --git a/entrypoint.sh b/entrypoint.sh
index 8c4136b..c6ce4f4 100644
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -7,16 +7,33 @@ PGID=${PGID:-1000}
# Modify group if PGID provided
if [ "$(id -g appuser)" != "$PGID" ]; then
echo "Changing appuser group to PGID $PGID"
- delgroup appuser >/dev/null 2>&1
- addgroup -g "$PGID" appgroup
- addgroup appuser appgroup
+ delgroup appuser >/dev/null 2>&1 || true
+ if getent group appgroup >/dev/null 2>&1; then
+ current_gid=$(getent group appgroup | cut -d: -f3)
+ if [ "$current_gid" != "$PGID" ]; then
+ echo "Error: appgroup exists with GID $current_gid, cannot change to $PGID" >&2
+ exit 1
+ fi
+ else
+ if ! addgroup -g "$PGID" appgroup; then
+ echo "Error: Failed to create group appgroup with GID $PGID" >&2
+ exit 1
+ fi
+ fi
+ if ! addgroup appuser appgroup; then
+ echo "Error: Failed to add appuser to appgroup" >&2
+ exit 1
+ fi
fi
# Modify user if PUID provided
if [ "$(id -u appuser)" != "$PUID" ]; then
echo "Changing appuser UID to $PUID"
deluser appuser >/dev/null 2>&1 || true
- adduser -S -u "$PUID" -G appgroup appuser
+ if ! adduser -S -u "$PUID" -G appgroup appuser 2>&1; then
+ echo "Error: Failed to create user appuser with UID $PUID" >&2
+ exit 1
+ fi
fi
# Ensure permissions on mounted volumes
diff --git a/pyproject.toml b/pyproject.toml
index 186914f..40bff99 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,6 +18,7 @@ dependencies = [
dev = [
"httpx>=0.28.1",
"pytest>=9.0.3",
+ "pytest-cov>=6.0.0",
"ruff>=0.14.2",
]
diff --git a/pytest.ini b/pytest.ini
index 7829689..33f8055 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,2 +1,5 @@
[pytest]
pythonpath = "."
+addopts = -m "not e2e"
+markers =
+ e2e: end-to-end tests requiring Vaultwarden and bw CLI
diff --git a/src/bw_client.py b/src/bw_client.py
index 8d67a89..7626533 100644
--- a/src/bw_client.py
+++ b/src/bw_client.py
@@ -320,3 +320,58 @@ def export_raw_encrypted(self, backup_file: str, file_pw: str):
)
with open(backup_file, "wb") as f:
f.write(encrypted_data)
+
+ def list_organizations(self) -> list[dict[str, Any]]:
+ """List all organizations the user has access to."""
+ logger.info("Fetching organization list...")
+ return self._run(["list", "organizations"])
+
+ def export_organization_raw(self, org_id: str) -> dict[str, Any]:
+ """Export organization vault as raw JSON."""
+ logger.info(f"Exporting organization vault: {org_id}")
+ return self._run(
+ cmd=["export", "--organizationid", org_id, "--format", "json", "--raw"],
+ capture_json=True,
+ )
+
+ def export_organization_bitwarden(
+ self, backup_file: str, file_pw: str, org_id: str
+ ):
+ """Export organization vault using Bitwarden's built-in encryption."""
+ try:
+ backup_file = validate_path(backup_file, "/app")
+ except BitwardenError as e:
+ logger.error(f"Invalid backup file path: {e}")
+ raise
+ logger.info(f"Exporting organization {org_id} with Bitwarden encryption...")
+ self._run(
+ cmd=[
+ "export",
+ "--organizationid",
+ org_id,
+ "--output",
+ backup_file,
+ "--format",
+ "encrypted_json",
+ "--password",
+ file_pw,
+ ],
+ capture_json=False,
+ )
+
+ def export_organization_raw_encrypted(
+ self, backup_file: str, file_pw: str, org_id: str
+ ):
+ """Export organization vault and encrypt it in-memory."""
+ try:
+ backup_file = validate_path(backup_file, "/app")
+ except BitwardenError as e:
+ logger.error(f"Invalid backup file path: {e}")
+ raise
+ logger.info(f"Exporting organization {org_id} with raw encryption...")
+ raw_json = self.export_organization_raw(org_id)
+ encrypted_data = self.encrypt_data(
+ json.dumps(raw_json).encode("utf-8"), file_pw
+ )
+ with open(backup_file, "wb") as f:
+ f.write(encrypted_data)
diff --git a/src/db.py b/src/db.py
index 4013f45..6aea357 100644
--- a/src/db.py
+++ b/src/db.py
@@ -123,8 +123,11 @@ def put_key(conn: sqlcipher3.Connection, name, value) -> None:
logging.debug("Key stored in database.")
-def get_key(conn: sqlcipher3.Connection, name: str) -> str:
- value = conn.execute("SELECT value FROM keys WHERE name = ?", (name,)).fetchone()[0]
+def get_key(conn: sqlcipher3.Connection, name: str) -> str | None:
+ row = conn.execute("SELECT value FROM keys WHERE name = ?", (name,)).fetchone()
+ if row is None:
+ return None
+ value = row[0]
if isinstance(value, bytes):
value = value.decode("utf-8")
return value
diff --git a/src/form.html b/src/form.html
index 2c44d70..1447b33 100644
--- a/src/form.html
+++ b/src/form.html
@@ -40,7 +40,8 @@
}
input[type="password"],
- input[type="text"] {
+ input[type="text"],
+ select {
width: 100%;
padding: 10px;
border-radius: 8px;
@@ -52,12 +53,44 @@
box-sizing: border-box;
}
- input:focus {
+ select {
+ cursor: pointer;
+ }
+
+ input:focus, select:focus {
outline: none;
border-color: #f15a24;
background: #1b1b1b;
}
+ .org-section {
+ text-align: left;
+ margin-top: 8px;
+ padding-top: 18px;
+ border-top: 1px solid #333;
+ }
+
+ .org-hint {
+ font-size: 12px;
+ color: #888;
+ margin-top: -14px;
+ margin-bottom: 18px;
+ }
+
+ .org-warning {
+ font-size: 12px;
+ color: #888;
+ margin-top: -14px;
+ margin-bottom: 18px;
+ }
+
+ .org-error {
+ font-size: 12px;
+ color: #ff6b6b;
+ margin-top: -14px;
+ margin-bottom: 18px;
+ }
+
button {
background: #f15a24;
border: none;
@@ -88,6 +121,26 @@
BackVault Setup
+
+
+
+
Single mode is not supported with Bitwarden encryption. Use "Separate files" or set BACKUP_ENCRYPTION_MODE=raw.
+
+
+
+
Leave empty to automatically export all accessible organizations.
+
diff --git a/src/init.py b/src/init.py
index 522751d..b636865 100644
--- a/src/init.py
+++ b/src/init.py
@@ -46,16 +46,24 @@ def init(
client_id: str = Form(...),
client_secret: str = Form(...),
file_password: str = Form(...),
+ organization_ids: str = Form(""),
+ org_export_mode: str = Form("multiple"),
):
conn, cursor = db_connect(DB_PATH, PRAGMA_KEY_FILE)
if not conn or not cursor:
return HTMLResponse("Database connection failed", status_code=500)
+ # Validate org_export_mode against allowlist
+ if org_export_mode not in ("single", "multiple", "none"):
+ org_export_mode = "multiple"
+
# Store encrypted passwords and keys
put_key(conn, "master_password", master_password.encode())
put_key(conn, "client_id", client_id.encode())
put_key(conn, "client_secret", client_secret.encode())
put_key(conn, "file_password", file_password.encode())
+ put_key(conn, "organization_ids", organization_ids.encode())
+ put_key(conn, "org_export_mode", org_export_mode.encode())
conn.close()
diff --git a/src/run.py b/src/run.py
index 74bb950..f569946 100644
--- a/src/run.py
+++ b/src/run.py
@@ -1,4 +1,5 @@
import os
+import json
import logging
from src.bw_client import BitwardenClient
from datetime import datetime
@@ -38,6 +39,19 @@ def main():
master_pw = get_key(db_conn, "master_password")
file_pw = get_key(db_conn, "file_password")
+ # Organization configuration
+ org_ids_raw = get_key(db_conn, "organization_ids")
+ org_export_mode_raw = get_key(db_conn, "org_export_mode")
+ raw_value = org_export_mode_raw
+ org_export_mode = raw_value if raw_value in ("single", "multiple", "none") else None
+ if raw_value is not None and raw_value not in ("single", "multiple", "none"):
+ logger.warning(f"Invalid org_export_mode '{raw_value}', ignoring")
+ configured_org_ids = (
+ [org.strip() for org in org_ids_raw.split(",") if org.strip()]
+ if org_ids_raw
+ else []
+ )
+
server = require_env("BW_SERVER")
if (
re.match(
@@ -88,23 +102,136 @@ def main():
logger.error(f"Unlock failed: {e}")
return
+ # Determine org IDs to export (use configured or fetch all)
+ # Skip API call when org exports are disabled
+ if org_export_mode is None or org_export_mode == "none":
+ org_ids = []
+ logger.info("Organization exports disabled")
+ elif configured_org_ids:
+ org_ids = configured_org_ids
+ logger.info(f"Exporting configured organizations: {org_ids}")
+ else:
+ try:
+ all_orgs = source.list_organizations()
+ org_ids = [org.get("id") for org in all_orgs if org.get("id")]
+ logger.info(f"Exporting all accessible organizations: {org_ids}")
+ except Exception as e:
+ logger.warning(
+ f"Failed to fetch organizations: {e}. No orgs will be exported."
+ )
+ org_ids = []
+
+ # Validate org IDs to prevent path traversal in filenames
+ # Keep original org_ids for export calls, create separate map for safe filenames
+ safe_suffixes = {}
+ seen_suffixes = set()
+ for org_id in org_ids:
+ if org_id is None:
+ continue
+ safe_id = re.sub(r"[^a-zA-Z0-9_-]", "_", org_id)
+ if safe_id != org_id:
+ logger.warning(
+ f"Org ID '{org_id}' contains unsafe characters, replaced with '{safe_id}'"
+ )
+ # Handle collisions by appending counter until unique
+ candidate = safe_id
+ counter = 0
+ while candidate in seen_suffixes:
+ counter += 1
+ candidate = f"{safe_id}_{counter}"
+ logger.warning(
+ f"Collision detected for '{safe_id}', using '{candidate}'"
+ )
+ seen_suffixes.add(candidate)
+ safe_suffixes[org_id] = candidate
+
# Generate timestamped filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
- backup_file = os.path.join(backup_dir, f"backup_{timestamp}.enc")
+ has_orgs = len(org_ids) > 0 and org_export_mode not in (None, "none")
+
+ # Export personal vault
+ # Use simple filename when org exports are disabled to maintain backward compatibility
+ if has_orgs:
+ personal_file = os.path.join(backup_dir, f"backup_{timestamp}_personal.enc")
+ else:
+ personal_file = os.path.join(backup_dir, f"backup_{timestamp}.enc")
logger.info(f"Starting export with mode: '{encryption_mode}'")
if encryption_mode == "raw":
- source.export_raw_encrypted(backup_file, file_pw)
+ source.export_raw_encrypted(personal_file, file_pw)
elif encryption_mode == "bitwarden":
- source.export_bitwarden_encrypted(backup_file, file_pw)
+ source.export_bitwarden_encrypted(personal_file, file_pw)
else:
logger.error(
f"Invalid BACKUP_ENCRYPTION_MODE: '{encryption_mode}'. Must be 'bitwarden' or 'raw'."
)
return
- logger.info(f"Export completed successfully to {backup_file}.")
+ logger.info(f"Personal vault export completed to {personal_file}.")
+
+ # Export organizations
+ # None means default to "none" for safe upgrade (existing users don't get unexpected exports)
+ if org_export_mode is None:
+ org_export_mode = "none"
+ logger.info(
+ "org_export_mode not configured, defaulting to 'none' for safe upgrade"
+ )
+
+ if org_export_mode == "none":
+ logger.info("Organization exports disabled by user configuration")
+ elif org_export_mode == "single" and has_orgs:
+ # Fail fast: single+bitwarden is an invalid combination
+ if encryption_mode == "bitwarden":
+ logger.error(
+ "org_export_mode='single' is not supported with encryption_mode='bitwarden'. "
+ "Aborting backup. Use org_export_mode='multiple' or switch to "
+ "encryption_mode='raw' to export organizations."
+ )
+ return
+
+ if encryption_mode == "raw":
+ all_org_data = {}
+ for org_id in org_ids:
+ try:
+ org_data = source.export_organization_raw(org_id)
+ all_org_data[org_id] = org_data
+ except Exception as e:
+ logger.warning(
+ f"Failed to export organization {org_id}: {e}. Skipping org."
+ )
+
+ if not all_org_data:
+ logger.error(
+ f"No organizations exported successfully. "
+ f"Skipping combined org backup (backup_{timestamp}_orgs.enc)."
+ )
+ else:
+ combined_data = json.dumps(all_org_data).encode("utf-8")
+ encrypted_data = source.encrypt_data(combined_data, file_pw)
+ org_file = os.path.join(backup_dir, f"backup_{timestamp}_orgs.enc")
+ with open(org_file, "wb") as f:
+ f.write(encrypted_data)
+ logger.info(f"Organization export completed to {org_file}.")
+
+ elif org_export_mode == "multiple" and has_orgs:
+ for org_id in org_ids:
+ safe_suffix = safe_suffixes.get(org_id, org_id)
+ org_file = os.path.join(
+ backup_dir, f"backup_{timestamp}_org-{safe_suffix}.enc"
+ )
+ try:
+ if encryption_mode == "raw":
+ source.export_organization_raw_encrypted(
+ org_file, file_pw, org_id
+ )
+ elif encryption_mode == "bitwarden":
+ source.export_organization_bitwarden(org_file, file_pw, org_id)
+ logger.info(f"Organization export completed: {org_file}")
+ except Exception as e:
+ logger.warning(
+ f"Failed to export organization {org_id}: {e}. Skipping org."
+ )
finally:
source.logout()
logger.info("Successfully logged out.")
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..c8d898f
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,194 @@
+# Testing Guide
+
+This document describes how to run tests for Backvault.
+
+## Prerequisites
+
+- Python 3.12+
+- [uv](https://github.com/astral-sh/uv) package manager
+- Docker (for Docker/E2E tests)
+- Bitwarden CLI (`bw`)
+
+## Running Tests Locally
+
+### Unit Tests
+
+Run all unit tests with:
+
+```bash
+uv sync --dev
+uv run pytest
+```
+
+### With Coverage Report
+
+```bash
+uv run pytest --cov=src --cov-report=html --cov-report=term
+```
+
+### Run Specific Test File
+
+```bash
+uv run pytest tests/test_bw_client.py -v
+uv run pytest tests/test_run.py -v
+uv run pytest tests/test_cli_integration.py -v
+```
+
+## Integration Tests (Requires Vaultwarden)
+
+### Quick Start with Docker
+
+1. Start a Vaultwarden instance:
+
+```bash
+docker run -d --name vaultwarden-test \
+ -p 8080:80 \
+ -e SIGNUPS_ALLOWED=true \
+ vaultwarden/server:latest
+```
+
+## Wait for it to be ready (~10 seconds)
+
+```bash
+sleep 10
+```
+
+## Set environment variables
+
+```bash
+export VAULTWARDEN_URL=http://localhost:8080
+export BW_TEST_EMAIL=your-test-email@example.com
+export BW_TEST_PASSWORD=your-test-password
+export BW_TEST_MASTER_PASSWORD=your-master-password
+```
+
+## Run E2E tests
+
+```bash
+uv run pytest tests/test_e2e.py -v
+```
+
+### Manual CLI Testing
+
+```bash
+# Configure server
+bw config server http://localhost:8080
+
+# Login
+bw login $BW_TEST_EMAIL --password $BW_TEST_PASSWORD
+
+# Unlock
+bw unlock $BW_TEST_MASTER_PASSWORD --raw
+
+# Export vault
+bw export --format json --password backup_password
+
+# Check status
+bw status
+```
+
+## Docker Image Testing
+
+### Multi-Arch Build
+
+```bash
+# Build and test per platform (recommended)
+for platform in linux/amd64 linux/arm64 linux/arm/v7; do
+ docker buildx build --platform=$platform --load -t myimage:$(echo $platform | tr '/' '-') .
+ docker run --rm --platform $platform myimage:$(echo $platform | tr '/' '-') bw --version
+done
+
+# Or use the provided test script
+./tests/docker_test.sh
+```
+
+### Run Full Docker Test Suite
+
+```bash
+./tests/docker_test.sh
+```
+
+## CI/CD
+
+Tests run automatically on GitHub Actions:
+
+| Workflow | Trigger | Jobs |
+|----------|---------|------|
+| `ci.yml` | Push/PR to main | Lint, Unit Tests, Docker Build |
+| `e2e.yml` | After CI success | E2E Tests with Vaultwarden |
+
+### Manual CI Run
+
+You can trigger E2E tests manually:
+
+1. Go to Actions > E2E Tests
+2. Click "Run workflow"
+3. Select branch and run
+
+## Test Organization
+
+```
+tests/
+βββ test_bw_client.py # Bitwarden client unit tests (mocked)
+βββ test_db.py # Database operations (mocked)
+βββ test_init.py # FastAPI init endpoints (mocked)
+βββ test_run.py # Main backup logic (mocked)
+βββ test_cli_integration.py # CLI workflow tests (mocked)
+βββ test_e2e.py # End-to-end tests (real Vaultwarden)
+βββ docker_test.sh # Docker image validation script
+βββ README.md # This file
+```
+
+## Troubleshooting
+
+### "Vaultwarden failed to start"
+
+Increase the wait time in the test fixture or check Docker logs:
+
+```bash
+docker logs vaultwarden-test
+```
+
+### "Failed to connect to Vaultwarden"
+
+Ensure the container is running and port 8080 is available:
+
+```bash
+docker ps
+curl http://localhost:8080/health
+```
+
+### SQLCipher errors
+
+Ensure system dependencies are installed:
+
+```bash
+# Ubuntu/Debian
+sudo apt-get install libsqlite3-dev libsqlcipher-dev libssl-dev
+
+# Alpine
+apk add sqlite-dev sqlcipher-dev libressl-dev
+```
+
+### "bw: command not found"
+
+Install Bitwarden CLI:
+
+```bash
+# macOS
+brew install bitwarden-cli
+
+# Linux
+npm install -g @bitwarden/cli
+```
+
+### Test timeout with Vaultwarden
+
+If Vaultwarden takes too long to start, increase the timeout in tests/test_e2e.py:
+
+```python
+# Change this:
+max_attempts = 30
+# To this:
+max_attempts = 60
+```
\ No newline at end of file
diff --git a/tests/docker_test.sh b/tests/docker_test.sh
new file mode 100755
index 0000000..31ff8a9
--- /dev/null
+++ b/tests/docker_test.sh
@@ -0,0 +1,101 @@
+#!/bin/bash
+# Docker Image Test Script
+# Validates the Docker image across all supported architectures
+
+set -e
+
+REPO_NAME="${GITHUB_REPOSITORY:-$(git rev-parse --show-toplevel 2>/dev/null | xargs basename 2>/dev/null || echo backvault)}"
+IMAGE_NAME="${IMAGE_NAME:-ghcr.io/$(echo "$REPO_NAME" | tr '[:upper:]' '[:lower:]')}"
+PLATFORMS=("linux/amd64" "linux/arm64" "linux/arm/v7")
+
+echo "=== Docker Image Tests ==="
+echo "Image: $IMAGE_NAME"
+echo "Platforms: ${PLATFORMS[*]}"
+echo ""
+
+if ! command -v docker &> /dev/null; then
+ echo "ERROR: Docker is not installed"
+ exit 1
+fi
+
+if ! docker buildx version &> /dev/null; then
+ echo "ERROR: Docker buildx is not available"
+ exit 1
+fi
+
+CREATED_BUILDER=0
+if ! docker buildx inspect &> /dev/null; then
+ echo "Setting up docker buildx..."
+ docker buildx create --name backvault-builder --use || true
+ CREATED_BUILDER=1
+fi
+
+cleanup() {
+ echo "Cleaning up..."
+ if [ "$CREATED_BUILDER" = "1" ]; then
+ docker buildx rm backvault-builder 2>/dev/null || true
+ fi
+}
+trap cleanup EXIT
+
+for platform in "${PLATFORMS[@]}"; do
+ platform_tag=$(echo "$platform" | tr '/' '-')
+ echo "=== Testing platform: $platform ==="
+
+ echo "Building image for $platform..."
+ docker buildx build \
+ --platform "$platform" \
+ --load \
+ -t "${IMAGE_NAME}:${platform_tag}-test" \
+ . || { echo "Build failed for $platform"; exit 1; }
+
+ echo "Testing bw --version..."
+ docker run --rm --platform "$platform" \
+ "${IMAGE_NAME}:${platform_tag}-test" \
+ bw --version
+
+ echo "Testing supercronic --version..."
+ docker run --rm --platform "$platform" \
+ "${IMAGE_NAME}:${platform_tag}-test" \
+ supercronic --version
+
+ echo "Testing python3 --version..."
+ docker run --rm --platform "$platform" \
+ "${IMAGE_NAME}:${platform_tag}-test" \
+ python3 --version
+
+ echo "Testing sqlcipher --version..."
+ docker run --rm --platform "$platform" \
+ "${IMAGE_NAME}:${platform_tag}-test" \
+ sqlcipher --version || echo "Note: sqlcipher may not have --version"
+
+ echo "Testing entrypoint.sh exists and is executable..."
+ docker run --rm --platform "$platform" \
+ "${IMAGE_NAME}:${platform_tag}-test" \
+ test -x /app/entrypoint.sh
+
+ echo "Testing run.sh exists and is executable..."
+ docker run --rm --platform "$platform" \
+ "${IMAGE_NAME}:${platform_tag}-test" \
+ test -x /app/run.sh
+
+ echo "Testing cleanup.sh exists and is executable..."
+ docker run --rm --platform "$platform" \
+ "${IMAGE_NAME}:${platform_tag}-test" \
+ test -x /app/cleanup.sh
+
+ echo "Testing required directories exist..."
+ docker run --rm --platform "$platform" \
+ "${IMAGE_NAME}:${platform_tag}-test" \
+ sh -c 'test -d /app/backups && test -d /app/db && test -d /app/logs'
+
+ echo "Testing environment variables..."
+ docker run --rm --platform "$platform" \
+ "${IMAGE_NAME}:${platform_tag}-test" \
+ printenv | grep -q PYTHONPATH || echo "Note: PYTHONPATH may not be set in test"
+
+ echo "=== $platform: All tests passed ==="
+ echo ""
+done
+
+echo "=== All Docker tests passed! ==="
diff --git a/tests/test_bw_client.py b/tests/test_bw_client.py
index 1477836..79a572a 100644
--- a/tests/test_bw_client.py
+++ b/tests/test_bw_client.py
@@ -1,5 +1,6 @@
+import json
import pytest
-from unittest.mock import patch, ANY
+from unittest.mock import patch, ANY, MagicMock
from src.bw_client import BitwardenClient, BitwardenError
@@ -196,3 +197,114 @@ def test_encrypt_data():
password = "test_password"
encrypted_data = client.encrypt_data(data, password)
assert encrypted_data != data
+
+
+@patch("src.bw_client.sprun")
+def test_list_organizations(mock_sprun):
+ """
+ Tests that list_organizations returns organization list.
+ """
+ mock_sprun.return_value.stdout = json.dumps(
+ [
+ {"id": "org1", "name": "Org 1"},
+ {"id": "org2", "name": "Org 2"},
+ ]
+ )
+ mock_sprun.return_value.returncode = 0
+
+ client = BitwardenClient(session="test_session")
+ orgs = client.list_organizations()
+
+ assert len(orgs) == 2
+ assert orgs[0]["id"] == "org1"
+ mock_sprun.assert_called_once_with(
+ ["bw", "list", "organizations"],
+ text=True,
+ capture_output=True,
+ check=True,
+ env=ANY,
+ )
+
+
+@patch("src.bw_client.sprun")
+def test_export_organization_bitwarden(mock_sprun, monkeypatch):
+ """
+ Tests organization export with Bitwarden encryption.
+ """
+ monkeypatch.setenv("TEST_MODE", "1")
+ mock_sprun.return_value.returncode = 0
+ mock_sprun.return_value.stdout = ""
+
+ client = BitwardenClient(session="test_session")
+
+ client.export_organization_bitwarden("/tmp/backups/org.enc", "file_pw", "org123")
+
+ mock_sprun.assert_called_once()
+ call_args = str(mock_sprun.call_args)
+ assert "--organizationid" in call_args
+ assert "org123" in call_args
+ assert "/tmp/backups/org.enc" in call_args
+
+
+@patch("src.bw_client.sprun")
+@patch("builtins.open", new_callable=MagicMock)
+def test_export_organization_raw_encrypted(mock_file, mock_sprun, monkeypatch):
+ """
+ Tests organization export with raw (AES-256-GCM) encryption.
+ """
+ monkeypatch.setenv("TEST_MODE", "1")
+ mock_sprun.return_value = MagicMock(
+ returncode=0,
+ stdout=json.dumps({"items": [{"id": "1", "name": "Item 1"}]}),
+ stderr="",
+ )
+
+ client = BitwardenClient(session="test_session")
+
+ client.export_organization_raw_encrypted(
+ "/tmp/backups/org.enc", "file_pw", "org123"
+ )
+
+ mock_sprun.assert_called()
+ call_args = str(mock_sprun.call_args)
+ assert "--organizationid" in call_args
+ assert "org123" in call_args
+
+
+@patch("src.bw_client.sprun")
+def test_status(mock_sprun):
+ """
+ Tests status method returns vault status.
+ """
+ mock_sprun.return_value.stdout = json.dumps(
+ {"status": "unlocked", "userId": "user123"}
+ )
+ mock_sprun.return_value.returncode = 0
+
+ client = BitwardenClient(session="test_session")
+ status = client.status()
+
+ assert status["status"] == "unlocked"
+ mock_sprun.assert_called_once_with(
+ ["bw", "status"], text=True, capture_output=True, check=True, env=ANY
+ )
+
+
+@patch("src.bw_client.sprun")
+def test_export_organization_raw(mock_sprun):
+ """
+ Tests export_organization_raw returns raw JSON.
+ """
+ expected_org_data = {"items": [{"id": "1", "name": "Test Item"}]}
+ mock_sprun.return_value.stdout = json.dumps(expected_org_data)
+ mock_sprun.return_value.returncode = 0
+
+ client = BitwardenClient(session="test_session")
+ org_data = client.export_organization_raw("org123")
+
+ assert org_data == expected_org_data
+ call_args = mock_sprun.call_args[0][0]
+ assert call_args[0] == "bw"
+ assert "--organizationid" in call_args
+ assert "org123" in call_args
+ assert "--raw" in call_args
diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py
new file mode 100644
index 0000000..3332b93
--- /dev/null
+++ b/tests/test_cli_integration.py
@@ -0,0 +1,148 @@
+"""
+Unit tests for CLI integration workflow (mock-based).
+
+These tests verify that the CLI commands are called correctly
+when orchestrating a backup workflow. They use mocks to avoid
+requiring a real Vaultwarden instance.
+
+Run with: uv run pytest tests/test_cli_integration.py -v
+"""
+
+import json
+from unittest.mock import patch, MagicMock
+from src.bw_client import BitwardenClient
+
+
+class TestCLIWorkflow:
+ """Tests for complete CLI workflows."""
+
+ @patch("src.bw_client.sprun")
+ def test_full_backup_workflow_bitwarden_mode(self, mock_sprun, monkeypatch):
+ """
+ Test complete backup workflow: login -> unlock -> export personal ->
+ export orgs (single) -> logout.
+
+ Uses bitwarden encryption mode.
+ """
+ monkeypatch.setenv("TEST_MODE", "1")
+ mock_sprun.side_effect = [
+ MagicMock(returncode=0, stdout="", stderr=""),
+ MagicMock(returncode=0, stdout="session_key_123", stderr=""),
+ MagicMock(returncode=0, stdout="unlocked_session", stderr=""),
+ MagicMock(returncode=0, stdout="", stderr=""),
+ MagicMock(
+ returncode=0,
+ stdout=json.dumps([{"id": "org1", "name": "Test Org"}]),
+ stderr="",
+ ),
+ MagicMock(returncode=0, stdout="", stderr=""),
+ MagicMock(returncode=0, stdout="", stderr=""),
+ ]
+
+ client = BitwardenClient(
+ client_id="test_client_id",
+ client_secret="test_client_secret",
+ use_api_key=True,
+ server="https://vault.example.com",
+ )
+
+ client.login()
+ client.unlock("master_password")
+
+ client.export_bitwarden_encrypted("/tmp/backups/personal.enc", "file_pw")
+
+ orgs = client.list_organizations()
+ for org in orgs:
+ client.export_organization_bitwarden(
+ f"/tmp/backups/org-{org['id']}.enc", "file_pw", org["id"]
+ )
+
+ client.logout()
+
+ assert mock_sprun.call_count == 7
+ calls = [str(c) for c in mock_sprun.call_args_list]
+
+ assert any("login" in c and "--apikey" in c for c in calls)
+ assert any("unlock" in c for c in calls)
+ assert any("export" in c and "personal" in c for c in calls)
+ assert any("export" in c and "--organizationid" in c for c in calls)
+
+
+class TestCLIEncryption:
+ """Tests for encryption functionality."""
+
+ def test_encrypt_data_produces_different_output(self):
+ """
+ Test that encrypt_data produces encrypted output different from input.
+ """
+ client = BitwardenClient()
+ data = b"secret data here"
+ password = "strong_password"
+
+ encrypted = client.encrypt_data(data, password)
+
+ assert encrypted != data
+ assert len(encrypted) > len(data)
+
+ def test_encrypt_data_produces_consistent_encryption(self):
+ """
+ Test that encrypt_data produces different output each time (due to random salt/nonce).
+ """
+ client = BitwardenClient()
+ data = b"same data"
+ password = "same_password"
+
+ encrypted1 = client.encrypt_data(data, password)
+ encrypted2 = client.encrypt_data(data, password)
+
+ assert encrypted1 != encrypted2
+ assert encrypted1[:16] != encrypted2[:16]
+
+ def test_encrypt_data_format(self):
+ """
+ Test encrypted data has correct format: salt(16) + nonce(12) + ciphertext + tag(16).
+ """
+ client = BitwardenClient()
+ data = b"test data"
+ password = "test_password"
+
+ encrypted = client.encrypt_data(data, password)
+
+ salt = encrypted[:16]
+ nonce = encrypted[16:28]
+ ciphertext_with_tag = encrypted[28:]
+
+ assert len(salt) == 16
+ assert len(nonce) == 12
+ assert len(ciphertext_with_tag) > 0
+
+ def test_encrypt_data_decryptable(self):
+ """
+ Test that encrypted data can be decrypted with the same password.
+ """
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
+ from cryptography.hazmat.primitives import hashes
+
+ client = BitwardenClient()
+ original_data = b"secret data"
+ password = "test_password"
+
+ encrypted = client.encrypt_data(original_data, password)
+
+ salt = encrypted[:16]
+ nonce = encrypted[16:28]
+ ciphertext_tag = encrypted[28:]
+
+ kdf = PBKDF2HMAC(
+ algorithm=hashes.SHA256(),
+ length=32,
+ salt=salt,
+ iterations=600000,
+ )
+ key = kdf.derive(password.encode("utf-8"))
+
+ aesgcm = AESGCM(key)
+ decrypted = aesgcm.decrypt(nonce, ciphertext_tag, None)
+
+ assert decrypted == original_data
diff --git a/tests/test_db.py b/tests/test_db.py
index ec50f75..8beac93 100644
--- a/tests/test_db.py
+++ b/tests/test_db.py
@@ -128,6 +128,7 @@ def test_put_key():
("test_name", "test_value"),
)
+
def test_get_key():
"""
Tests that get_key retrieves a value by its name.
diff --git a/tests/test_e2e.py b/tests/test_e2e.py
new file mode 100644
index 0000000..128dc89
--- /dev/null
+++ b/tests/test_e2e.py
@@ -0,0 +1,378 @@
+"""
+End-to-End Tests with Real Vaultwarden
+
+These tests require:
+- Vaultwarden running (see fixtures)
+- Test user credentials configured via environment variables
+
+Marked with @pytest.mark.e2e - excluded from default pytest run.
+
+Run with:
+ VAULTWARDEN_URL=http://localhost:8888 \
+ BW_TEST_EMAIL=test@example.com \
+ BW_TEST_PASSWORD=testpassword123 \
+ BW_TEST_MASTER_PASSWORD=masterpassword123 \
+ uv run pytest tests/test_e2e.py -v -m e2e
+"""
+
+import json
+import os
+import subprocess
+import time
+from urllib.parse import urlparse
+
+import pytest
+
+pytestmark = pytest.mark.e2e
+
+VAULTWARDEN_URL = os.getenv("VAULTWARDEN_URL", "http://localhost:8888")
+VAULTWARDEN_PORT = str(urlparse(VAULTWARDEN_URL).port or 80)
+IMAGE_NAME = os.getenv("IMAGE_NAME", "backvault:latest")
+TEST_EMAIL = os.getenv("BW_TEST_EMAIL", "e2e@test.com")
+TEST_PASSWORD = os.getenv("BW_TEST_PASSWORD", "Test123!")
+TEST_MASTER_PASSWORD = os.getenv("BW_TEST_MASTER_PASSWORD", "Master123!")
+
+
+@pytest.fixture(scope="module")
+def vaultwarden_container():
+ """Start Vaultwarden container for testing, clean up after."""
+ container_name = "backvault-test-vaultwarden"
+
+ result = subprocess.run(
+ [
+ "docker",
+ "ps",
+ "--filter",
+ f"name={container_name}",
+ "--format",
+ "{{.Names}}",
+ ],
+ capture_output=True,
+ text=True,
+ )
+
+ if container_name in result.stdout:
+ print(f"Using existing container: {container_name}")
+ yield container_name
+ return
+
+ result = subprocess.run(
+ [
+ "docker",
+ "ps",
+ "--filter",
+ f"publish={VAULTWARDEN_PORT}",
+ "--filter",
+ "ancestor=vaultwarden/server",
+ "--format",
+ "{{.Names}}",
+ ],
+ capture_output=True,
+ text=True,
+ )
+ if result.stdout.strip():
+ container_names = result.stdout.strip().splitlines()
+ container_name = container_names[0] if container_names else ""
+ print(f"Using existing container on port {VAULTWARDEN_PORT}: {container_name}")
+ yield container_name
+ return
+
+ print(f"Starting Vaultwarden container: {container_name}")
+ subprocess.run(
+ [
+ "docker",
+ "run",
+ "-d",
+ "--name",
+ container_name,
+ "-p",
+ f"{VAULTWARDEN_PORT}:80",
+ "-e",
+ "SIGNUPS_ALLOWED=true",
+ "-e",
+ "ADMIN_TOKEN=admin_secret_token_for_testing",
+ "vaultwarden/server:latest",
+ ],
+ check=True,
+ )
+
+ max_attempts = 30
+ for attempt in range(max_attempts):
+ try:
+ result = subprocess.run(
+ ["curl", "-sf", f"{VAULTWARDEN_URL}/health"],
+ capture_output=True,
+ timeout=2,
+ )
+ if result.returncode == 0:
+ print(f"Vaultwarden ready after {attempt + 1} attempts")
+ break
+ except Exception:
+ pass
+ time.sleep(2)
+ else:
+ pytest.fail("Vaultwarden failed to start")
+
+ yield container_name
+
+ print(f"Stopping Vaultwarden container: {container_name}")
+ subprocess.run(["docker", "stop", container_name], capture_output=True)
+ subprocess.run(["docker", "rm", "-f", container_name], capture_output=True)
+
+
+@pytest.fixture(scope="function")
+def bw_env(tmp_path_factory):
+ """Create isolated Bitwarden CLI environment."""
+ appdata = tmp_path_factory.mktemp("bw_data")
+ return {**os.environ, "BITWARDENCLI_APPDATA_DIR": str(appdata)}
+
+
+@pytest.fixture(scope="function")
+def test_user(vaultwarden_container, bw_env):
+ """Create test user in Vaultwarden via admin API."""
+ import urllib.request
+ import urllib.error
+
+ admin_url = f"{VAULTWARDEN_URL}/admin/users"
+ req = urllib.request.Request(
+ admin_url,
+ data=urllib.parse.urlencode(
+ {
+ "email": TEST_EMAIL,
+ "password": TEST_PASSWORD,
+ "masterPassword": TEST_MASTER_PASSWORD,
+ }
+ ).encode(),
+ headers={
+ "Authorization": f"Bearer {os.getenv('VAULTWARDEN_ADMIN_TOKEN', 'admin_secret_token_for_testing')}"
+ },
+ method="POST",
+ )
+ try:
+ urllib.request.urlopen(req, timeout=10)
+ except urllib.error.HTTPError as e:
+ if e.code == 400 and "exists" in e.read().decode().lower():
+ pass
+ else:
+ pytest.fail(f"Registration failed: {e}")
+ except Exception as e:
+ pytest.fail(f"Registration failed: {e}")
+
+ yield {"email": TEST_EMAIL, "password": TEST_PASSWORD, "bw_env": bw_env}
+
+
+@pytest.fixture(scope="function")
+def bw_session(test_user):
+ """Create Bitwarden CLI session by logging in."""
+ bw_env = test_user["bw_env"]
+ subprocess.run(["bw", "config", "server", VAULTWARDEN_URL], check=True, env=bw_env)
+
+ subprocess.run(["bw", "logout"], capture_output=True, env=bw_env)
+
+ result = subprocess.run(
+ [
+ "bw",
+ "login",
+ TEST_EMAIL,
+ "--password",
+ TEST_PASSWORD,
+ "--raw",
+ ],
+ capture_output=True,
+ text=True,
+ env={**bw_env, "BW_SESSION": ""},
+ )
+
+ if result.returncode != 0:
+ pytest.fail(f"Login failed: {result.stderr}")
+
+ session = result.stdout.strip()
+
+ result = subprocess.run(
+ [
+ "bw",
+ "unlock",
+ TEST_MASTER_PASSWORD,
+ "--raw",
+ ],
+ capture_output=True,
+ text=True,
+ env={**bw_env, "BW_SESSION": session},
+ )
+
+ if result.returncode != 0:
+ pytest.fail(f"Unlock failed: {result.stderr}")
+
+ session = result.stdout.strip()
+ bw_env["BW_SESSION"] = session
+
+ yield {"session": session, "bw_env": bw_env}
+
+ subprocess.run(["bw", "lock"], capture_output=True, env=bw_env)
+ subprocess.run(["bw", "logout"], capture_output=True, env=bw_env)
+ if "BW_SESSION" in bw_env:
+ del bw_env["BW_SESSION"]
+
+
+class TestE2EBackup:
+ """End-to-end backup tests with real Vaultwarden."""
+
+ def test_cli_can_login_and_unlock(self, bw_session):
+ """Verify CLI can login and unlock vault."""
+ assert bw_session["session"]
+ assert len(bw_session["session"]) > 0
+
+ result = subprocess.run(
+ ["bw", "status"],
+ capture_output=True,
+ text=True,
+ env={**bw_session["bw_env"], "BW_SESSION": bw_session["session"]},
+ )
+ assert result.returncode == 0
+
+ status = json.loads(result.stdout)
+ assert status["status"] == "unlocked"
+
+ def test_list_items(self, bw_session):
+ """Test listing items in vault."""
+ result = subprocess.run(
+ ["bw", "list", "items"],
+ capture_output=True,
+ text=True,
+ env={**bw_session["bw_env"], "BW_SESSION": bw_session["session"]},
+ )
+
+ assert result.returncode == 0
+ items = json.loads(result.stdout)
+ assert isinstance(items, list)
+
+ def test_backup_personal_vault_raw_mode(self, bw_session, tmp_path, monkeypatch):
+ """Test backing up personal vault with raw (AES-256-GCM) encryption."""
+ from src.bw_client import BitwardenClient
+
+ monkeypatch.setenv("TEST_MODE", "1")
+ monkeypatch.setenv(
+ "BITWARDENCLI_APPDATA_DIR",
+ bw_session["bw_env"]["BITWARDENCLI_APPDATA_DIR"],
+ )
+
+ client = BitwardenClient(session=bw_session["session"], server=VAULTWARDEN_URL)
+
+ backup_file = tmp_path / "personal_raw.enc"
+ client.export_raw_encrypted(str(backup_file), "backup_password")
+
+ assert backup_file.exists()
+ assert backup_file.stat().st_size > 0
+
+ content = backup_file.read_bytes()
+ assert len(content) > 44
+
+ def test_list_organizations(self, bw_session):
+ """Test listing organizations (may be empty for personal account)."""
+ result = subprocess.run(
+ ["bw", "list", "organizations"],
+ capture_output=True,
+ text=True,
+ env={**bw_session["bw_env"], "BW_SESSION": bw_session["session"]},
+ )
+
+ assert result.returncode == 0
+ orgs = json.loads(result.stdout)
+ assert isinstance(orgs, list)
+
+ def test_status_returns_user_info(self, bw_session):
+ """Test status returns user information."""
+ result = subprocess.run(
+ ["bw", "status"],
+ capture_output=True,
+ text=True,
+ env={**bw_session["bw_env"], "BW_SESSION": bw_session["session"]},
+ )
+
+ assert result.returncode == 0
+ status = json.loads(result.stdout)
+ assert "status" in status
+ assert status["status"] == "unlocked"
+
+
+class TestE2EDocker:
+ """Test Docker image functionality."""
+
+ def test_docker_image_has_required_binaries(self):
+ """Verify Docker image has all required binaries."""
+ result = subprocess.run(
+ ["docker", "image", "inspect", IMAGE_NAME],
+ capture_output=True,
+ )
+ if result.returncode != 0:
+ pytest.skip(f"Image not built yet: {IMAGE_NAME}")
+
+ required_binaries = ["bw", "supercronic", "python3", "sqlcipher"]
+
+ for binary in required_binaries:
+ result = subprocess.run(
+ ["docker", "run", "--rm", IMAGE_NAME, "which", binary],
+ capture_output=True,
+ )
+ assert result.returncode == 0, f"Binary {binary} not found in image"
+
+ def test_entrypoint_exists(self):
+ """Verify entrypoint script is executable."""
+ result = subprocess.run(
+ [
+ "docker",
+ "run",
+ "--rm",
+ IMAGE_NAME,
+ "ls",
+ "-la",
+ "/app/entrypoint.sh",
+ ],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if result.returncode != 0:
+ err = (result.stderr or "").lower()
+ if (
+ "unable to find image" in err
+ or "pull access denied" in err
+ or "not found" in err
+ ):
+ pytest.skip("Image not built yet")
+ pytest.fail(f"Docker image check failed: {result.stderr}")
+
+ assert "-rwxr-xr-x" in result.stdout
+
+
+class TestE2EErrorHandling:
+ """Test error handling with real Vaultwarden."""
+
+ def test_invalid_session_handling(self, bw_session):
+ """Test that invalid session is handled gracefully."""
+ invalid_env = {**bw_session["bw_env"], "BW_SESSION": "invalid_session_key"}
+ result = subprocess.run(
+ ["bw", "status"],
+ capture_output=True,
+ text=True,
+ env=invalid_env,
+ )
+
+ assert result.returncode != 0
+
+ def test_unlock_with_wrong_password(self, bw_session):
+ """Test unlock with wrong password fails properly."""
+ session = bw_session["session"]
+ locked_env = {**bw_session["bw_env"], "BW_SESSION": session}
+
+ result = subprocess.run(["bw", "lock"], capture_output=True, env=locked_env)
+ assert result.returncode == 0, f"Lock failed: {result.stderr}"
+
+ result = subprocess.run(
+ ["bw", "unlock", "wrong_password", "--raw"],
+ capture_output=True,
+ text=True,
+ env={**locked_env, "BW_SESSION": session},
+ )
+
+ assert result.returncode != 0
diff --git a/tests/test_run.py b/tests/test_run.py
index 41e5646..8d404da 100644
--- a/tests/test_run.py
+++ b/tests/test_run.py
@@ -31,11 +31,16 @@ def test_main_bitwarden_encryption(
"test_client_secret",
"test_master_pw",
"test_file_pw",
+ "", # organization_ids (empty)
+ "multiple", # org_export_mode
]
mock_client_instance = mock_bw_client.return_value
mock_sprun.return_value = CompletedProcess(
args=[], returncode=0, stdout="", stderr=""
)
+ mock_client_instance.list_organizations = MagicMock(
+ return_value=[{"id": "org-1"}, {"id": "org-2"}]
+ )
main()
@@ -52,6 +57,7 @@ def test_main_bitwarden_encryption(
mock_client_instance.unlock.assert_called_once_with("test_master_pw")
mock_client_instance.export_bitwarden_encrypted.assert_called_once()
mock_client_instance.logout.assert_called_once()
+ mock_client_instance.list_organizations.assert_called_once()
@patch("src.run.db_connect")
@@ -79,11 +85,16 @@ def test_main_raw_encryption(mock_bw_client, mock_sprun, mock_get_key, mock_db_c
"test_client_secret",
"test_master_pw",
"test_file_pw",
+ "", # organization_ids (empty)
+ "multiple", # org_export_mode
]
mock_client_instance = mock_bw_client.return_value
mock_sprun.return_value = CompletedProcess(
args=[], returncode=0, stdout="", stderr=""
)
+ mock_client_instance.list_organizations = MagicMock(
+ return_value=[{"id": "org-1"}, {"id": "org-2"}]
+ )
main()
@@ -100,6 +111,7 @@ def test_main_raw_encryption(mock_bw_client, mock_sprun, mock_get_key, mock_db_c
mock_client_instance.unlock.assert_called_once_with("test_master_pw")
mock_client_instance.export_raw_encrypted.assert_called_once()
mock_client_instance.logout.assert_called_once()
+ mock_client_instance.list_organizations.assert_called_once()
@patch("src.run.db_connect")
@@ -126,6 +138,8 @@ def test_main_invalid_encryption_mode(mock_bw_client, mock_get_key, mock_db_conn
"test_client_secret",
"test_master_pw",
"test_file_pw",
+ "", # organization_ids (empty)
+ "multiple", # org_export_mode
]
mock_client_instance = mock_bw_client.return_value
@@ -158,9 +172,12 @@ def test_main_login_fails(mock_bw_client, mock_get_key, mock_db_connect):
"test_client_secret",
"test_master_pw",
"test_file_pw",
+ "", # organization_ids (empty)
+ "multiple", # org_export_mode
]
mock_client_instance = mock_bw_client.return_value
mock_client_instance.login.side_effect = Exception("Login failed")
+ mock_client_instance.list_organizations = MagicMock(return_value=[{"id": "org-1"}])
main()
@@ -191,9 +208,12 @@ def test_main_unlock_fails(mock_bw_client, mock_get_key, mock_db_connect):
"test_client_secret",
"test_master_pw",
"test_file_pw",
+ "", # organization_ids (empty)
+ "multiple", # org_export_mode
]
mock_client_instance = mock_bw_client.return_value
mock_client_instance.unlock.side_effect = Exception("Unlock failed")
+ mock_client_instance.list_organizations = MagicMock(return_value=[{"id": "org-1"}])
main()
diff --git a/uv.lock b/uv.lock
index 94af56a..bf73b53 100644
--- a/uv.lock
+++ b/uv.lock
@@ -53,6 +53,7 @@ dependencies = [
dev = [
{ name = "httpx" },
{ name = "pytest" },
+ { name = "pytest-cov" },
{ name = "ruff" },
]
@@ -72,6 +73,7 @@ requires-dist = [
dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pytest", specifier = ">=9.0.3" },
+ { name = "pytest-cov", specifier = ">=6.0.0" },
{ name = "ruff", specifier = ">=0.14.2" },
]
@@ -162,6 +164,90 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
+[[package]]
+name = "coverage"
+version = "7.13.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
+ { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
+ { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
+ { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
+ { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
+ { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
+ { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
+ { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
+ { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
+ { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
+ { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
+ { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
+ { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
+ { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
+ { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
+ { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
+ { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
+ { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
+ { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
+ { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
+ { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
+ { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
+ { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
+]
+
[[package]]
name = "cryptography"
version = "46.0.7"
@@ -438,6 +524,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
+[[package]]
+name = "pytest-cov"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage" },
+ { name = "pluggy" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
+]
+
[[package]]
name = "python-multipart"
version = "0.0.26"