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

+
+ + + + + + +

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"