From fffb3f382f335496f0667ba2a14b3c6e09eb4465 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Tue, 13 Jan 2026 10:04:43 +0100 Subject: [PATCH 01/38] Downgrade bw version to 2025.10 (last known fully compatible version) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 39f182a..8cac6a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.13-alpine -ARG BW_VERSION="2025.11.0" +ARG BW_VERSION="2025.10.0" ARG SUPERCRONIC_VERSION="v0.2.41" ARG SUPERCRONIC_SHA1SUM_LINUX_AMD64=f70ad28d0d739a96dc9e2087ae370c257e79b8d7 ARG SUPERCRONIC_SHA1SUM_LINUX_ARM64=44e10e33e8d98b1d1522f6719f15fb9469786ff0 From 3657bac3ae3fc8642df9809fdc41941335007fad Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Wed, 25 Feb 2026 09:54:19 +0100 Subject: [PATCH 02/38] Remove initial run if it's not the initial setup. Also accomodate for BACKUP_INTERVAL_HOURS > 23 by doing cron math --- run.sh | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/run.sh b/run.sh index 15dff0a..8a6aafb 100644 --- a/run.sh +++ b/run.sh @@ -5,7 +5,23 @@ set -euo pipefail if [ $# -eq 0 ]; then echo "Initializing Backvault container..." INTERVAL_HOURS=${BACKUP_INTERVAL_HOURS:-12} - CRON_EXPRESSION=${CRON_EXPRESSION:-"0 */$INTERVAL_HOURS * * *"} + if [ -z "${CRON_EXPRESSION:-}" ]; then + DAYS=$((INTERVAL_HOURS / 24)) + HOURS=$((INTERVAL_HOURS % 24)) + + CRON_DAY="*" + if [ "$DAYS" -gt 0 ]; then + CRON_DAY="*/$DAYS" + fi + + if [ "$HOURS" -gt 0 ]; then + CRON_HOUR="*/$HOURS" + else + CRON_HOUR="0" + fi + + CRON_EXPRESSION="0 $CRON_HOUR $CRON_DAY * *" + fi UI_HOST="${SETUP_UI_HOST:-0.0.0.0}" UI_PORT="${SETUP_UI_PORT:-8080}" DB_FILE="/app/db/backvault.db" @@ -49,9 +65,13 @@ EOF cd /app fi - echo "Running initial backup..." - - ./run_wrapper.sh + BACKUP_DIR=${BACKUP_DIR:-"/app/backups"} + if [ -d "$BACKUP_DIR" ] && [ "$(ls -A "$BACKUP_DIR" 2>/dev/null)" ]; then + echo "Found existing backups in $BACKUP_DIR, skipping initial backup." + else + echo "Running initial backup..." + ./run_wrapper.sh + fi echo "Starting supercronic scheduler..." exec /usr/local/bin/supercronic /app/crontab From 7ca7a2add83999e2d677c824c436d3d1889eda83 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Mon, 20 Apr 2026 16:32:59 +0200 Subject: [PATCH 03/38] Add comprehensive testing infrastructure - Add test_cli_integration.py: Mock-based CLI workflow tests - Add test_e2e.py: Real Vaultwarden E2E tests - Add docker_test.sh: Multi-arch Docker validation script - Add tests/README.md: Testing documentation - Enhance test_bw_client.py: Add org export and status tests - Update ci.yml: Add lint, unit tests, multi-arch Docker, Codecov, image push - Add e2e.yml: E2E workflow triggered after CI success --- .github/workflows/ci.yml | 186 ++++++++++++++++---- .github/workflows/e2e.yml | 90 ++++++++++ tests/README.md | 193 ++++++++++++++++++++ tests/docker_test.sh | 96 ++++++++++ tests/test_bw_client.py | 113 +++++++++++- tests/test_cli_integration.py | 151 ++++++++++++++++ tests/test_e2e.py | 319 ++++++++++++++++++++++++++++++++++ 7 files changed, 1114 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 tests/README.md create mode 100755 tests/docker_test.sh create mode 100644 tests/test_cli_integration.py create mode 100644 tests/test_e2e.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 571ba16..2e43bae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,44 +1,87 @@ name: CI permissions: contents: read + pull-requests: write on: push: - branches: [ "main" ] + branches: [main] pull_request: - branches: [ "main" ] + branches: [main] env: IMAGE_NAME: ${{ github.repository }} 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 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() && (success() || failure()) + 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 +89,95 @@ 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: Build Docker image + uses: docker/build-push-action@v5 + with: + platforms: ${{ matrix.platform }} + load: true + tags: | + ${{ env.IMAGE_NAME }}:${{ matrix.platform }}-test + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Verify Bitwarden CLI 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 }}:${{ matrix.platform }}-test \ + bw --version + + - name: Verify supercronic + run: | + docker run --rm --platform ${{ matrix.platform }} \ + ${{ env.IMAGE_NAME }}:${{ matrix.platform }}-test \ + supercronic --version + + - name: Verify Python 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 }}:${{ matrix.platform }}-test \ + python3 --version + + - name: Verify entrypoint exists + run: | + docker run --rm --platform ${{ matrix.platform }} \ + ${{ env.IMAGE_NAME }}:${{ matrix.platform }}-test \ + ls -la /app/entrypoint.sh + + - name: Verify run script exists + run: | + docker run --rm --platform ${{ matrix.platform }} \ + ${{ env.IMAGE_NAME }}:${{ matrix.platform }}-test \ + ls -la /app/run.sh + + - name: Verify required directories + run: | + docker run --rm --platform ${{ matrix.platform }} \ + ${{ env.IMAGE_NAME }}:${{ matrix.platform }}-test \ + ls -la /app/backups /app/db /app/logs + + docker-push: + name: Docker Push + needs: docker-build + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + 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: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:${{ github.sha }} + ${{ env.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..84e5429 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,90 @@ +name: E2E Tests +permissions: + contents: read + actions: read + +on: + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + +env: + IMAGE_NAME: ${{ github.repository }} + VAULTWARDEN_URL: http://localhost:8080 + +jobs: + e2e: + name: E2E Tests + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + 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: Build Docker image + uses: docker/build-push-action@v5 + with: + platforms: linux/amd64 + load: true + tags: ${{ env.IMAGE_NAME }}:e2e-test + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Start Vaultwarden + run: | + docker run -d \ + --name vaultwarden-test \ + -p 8080:80 \ + -e SIGNUPS_ALLOWED=true \ + -e ADMIN_TOKEN=admin_secret_token_for_testing \ + vaultwarden/server:latest + + - name: Wait for Vaultwarden to start + run: | + echo "Waiting for Vaultwarden..." + for i in $(seq 1 30); do + if curl -sf http://localhost:8080/health > /dev/null 2>&1; then + echo "Vaultwarden is ready" + break + fi + echo "Attempt $i/30: Waiting..." + sleep 2 + done + + - name: Verify Vaultwarden is running + run: | + curl -sf http://localhost:8080/health || exit 1 + + - name: Configure Bitwarden CLI + run: | + bw config server ${{ env.VAULTWARDEN_URL }} + + - name: Install dependencies + run: | + pip install uv + uv sync --dev + + - name: Run E2E tests + run: | + VAULTWARDEN_URL=${{ env.VAULTWARDEN_URL }} \ + BW_TEST_EMAIL=e2e@test.com \ + BW_TEST_PASSWORD=Test123! \ + BW_TEST_MASTER_PASSWORD=Master123! \ + uv run pytest tests/test_e2e.py -v --timeout=300 + + - name: Cleanup Vaultwarden + if: always() + run: | + docker stop vaultwarden-test || true + docker rm vaultwarden-test || true + + - name: Cleanup Docker buildx + if: always() + run: | + docker buildx prune --all || true \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..97d3bca --- /dev/null +++ b/tests/README.md @@ -0,0 +1,193 @@ +# Testing Guide + +This document describes how to run tests for Backvault. + +## Prerequisites + +- Python 3.13+ +- [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 +``` + +2. Wait for it to be ready (~10 seconds) + +```bash +sleep 10 +``` + +3. 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 +``` + +4. 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 for all platforms +docker buildx build --platform=linux/amd64,linux/arm64,linux/arm/v7 --load . + +# Test each platform +docker run --rm --platform linux/amd64 bw --version +docker run --rm --platform linux/arm64 bw --version +docker run --rm --platform linux/arm/v7 bw --version +``` + +### 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..ae9ea58 --- /dev/null +++ b/tests/docker_test.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Docker Image Test Script +# Validates the Docker image across all supported architectures + +set -e + +IMAGE_NAME="${IMAGE_NAME:-ghcr.io/$(echo $GITHUB_REPOSITORY | 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 + +if ! docker buildx inspect &> /dev/null; then + echo "Setting up docker buildx..." + docker buildx create --name backvault-builder --use || true +fi + +cleanup() { + echo "Cleaning up..." + docker buildx rm backvault-builder 2>/dev/null || true +} +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" \ + test -d /app/backups /app/db /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..76ba6cc 100644 --- a/tests/test_bw_client.py +++ b/tests/test_bw_client.py @@ -1,5 +1,7 @@ +import json +import os import pytest -from unittest.mock import patch, ANY +from unittest.mock import patch, ANY, MagicMock from src.bw_client import BitwardenClient, BitwardenError @@ -196,3 +198,112 @@ 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): + """ + Tests organization export with Bitwarden encryption. + """ + mock_sprun.return_value.returncode = 0 + mock_sprun.return_value.stdout = "" + + client = BitwardenClient(session="test_session") + + os.environ["TEST_MODE"] = "1" + try: + client.export_organization_bitwarden("/tmp/backups/org.enc", "file_pw", "org123") + except Exception: + pass + finally: + del os.environ["TEST_MODE"] + + mock_sprun.assert_called_once() + + +@patch("src.bw_client.sprun") +@patch("builtins.open", new_callable=MagicMock) +def test_export_organization_raw_encrypted(mock_file, mock_sprun): + """ + Tests organization export with raw (AES-256-GCM) encryption. + """ + mock_sprun.return_value = MagicMock( + returncode=0, + stdout=json.dumps({"items": [{"id": "1", "name": "Item 1"}]}), + stderr="" + ) + + client = BitwardenClient(session="test_session") + + os.environ["TEST_MODE"] = "1" + try: + client.export_organization_raw_encrypted("/tmp/backups/org.enc", "file_pw", "org123") + except Exception: + pass + finally: + del os.environ["TEST_MODE"] + + mock_sprun.assert_called() + + +@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..2505bf2 --- /dev/null +++ b/tests/test_cli_integration.py @@ -0,0 +1,151 @@ +""" +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 +import os +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): + """ + Test complete backup workflow: login -> unlock -> export personal -> + export orgs (single) -> logout. + + Uses bitwarden encryption mode. + """ + os.environ["TEST_MODE"] = "1" + try: + 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) + finally: + del os.environ["TEST_MODE"] + + +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_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..4d387cb --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,319 @@ +""" +End-to-End Tests with Real Vaultwarden + +These tests require: +- Vaultwarden running (see fixtures) +- Test user credentials configured via environment variables + +Run with: + VAULTWARDEN_URL=http://localhost:8080 \ + BW_TEST_EMAIL=test@example.com \ + BW_TEST_PASSWORD=testpassword123 \ + BW_TEST_MASTER_PASSWORD=masterpassword123 \ + uv run pytest tests/test_e2e.py -v +""" +import json +import os +import subprocess +import time + +import pytest + +VAULTWARDEN_URL = os.getenv("VAULTWARDEN_URL", "http://localhost:8080") +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 + + print(f"Starting Vaultwarden container: {container_name}") + subprocess.run( + [ + "docker", + "run", + "-d", + "--name", + container_name, + "-p", + "8080: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") + + time.sleep(5) + + 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="module") +def test_user(vaultwarden_container): + """Create test user in Vaultwarden.""" + subprocess.run(["bw", "config", "server", VAULTWARDEN_URL], check=True) + + subprocess.run(["bw", "logout"], capture_output=True) + + result = subprocess.run( + [ + "bw", + "register", + TEST_EMAIL, + "--password", + TEST_PASSWORD, + "--master-password", + TEST_MASTER_PASSWORD, + ], + capture_output=True, + text=True, + ) + + if result.returncode != 0 and "already exists" not in result.stderr.lower(): + if "can only be" in result.stderr.lower(): + pass + else: + print(f"Register output: {result.stdout}") + print(f"Register error: {result.stderr}") + + yield {"email": TEST_EMAIL, "password": TEST_PASSWORD} + + +@pytest.fixture(scope="module") +def bw_session(test_user): + """Create Bitwarden CLI session by logging in.""" + subprocess.run(["bw", "config", "server", VAULTWARDEN_URL], check=True) + + subprocess.run(["bw", "logout"], capture_output=True) + + result = subprocess.run( + [ + "bw", + "login", + TEST_EMAIL, + "--password", + TEST_PASSWORD, + "--raw", + ], + capture_output=True, + text=True, + env={**os.environ, "BW_SESSION": ""}, + ) + + if result.returncode != 0: + if "not found" in result.stderr.lower() or "invalid" in result.stderr.lower(): + pytest.skip(f"Cannot login to test Vaultwarden: {result.stderr}") + else: + pytest.fail(f"Failed to login: {result.stderr}") + + session = result.stdout.strip() + + result = subprocess.run( + [ + "bw", + "unlock", + TEST_MASTER_PASSWORD, + "--raw", + ], + capture_output=True, + text=True, + env={**os.environ, "BW_SESSION": session}, + ) + + if result.returncode != 0: + pytest.skip(f"Cannot unlock vault: {result.stderr}") + + session = result.stdout.strip() + os.environ["BW_SESSION"] = session + + yield session + + subprocess.run(["bw", "lock"], capture_output=True) + subprocess.run(["bw", "logout"], capture_output=True) + if "BW_SESSION" in os.environ: + del os.environ["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 + assert len(bw_session) > 0 + + result = subprocess.run( + ["bw", "status"], + capture_output=True, + text=True, + env={**os.environ, "BW_SESSION": bw_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={**os.environ, "BW_SESSION": bw_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): + """Test backing up personal vault with raw (AES-256-GCM) encryption.""" + from src.bw_client import BitwardenClient + + client = BitwardenClient(session=bw_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={**os.environ, "BW_SESSION": bw_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={**os.environ, "BW_SESSION": bw_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.""" + required_binaries = ["bw", "supercronic", "python3"] + + for binary in required_binaries: + result = subprocess.run( + ["docker", "run", "--rm", "vaultwarden/server:latest", "which", binary], + capture_output=True, + ) + if result.returncode == 0 and binary == "bw": + print(f"Found {binary} in vaultwarden image") + continue + try: + subprocess.run( + ["docker", "run", "--rm", "backvault:latest", "which", binary], + capture_output=True, + check=True, + ) + except subprocess.CalledProcessError: + pytest.skip(f"Image not built yet: {binary}") + + def test_entrypoint_exists(self): + """Verify entrypoint script is executable.""" + try: + result = subprocess.run( + ["docker", "run", "--rm", "backvault:latest", "ls", "-la", "/app/entrypoint.sh"], + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError: + pytest.skip("Image not built yet") + + assert "-rwxr-xr-x" in result.stdout + + +class TestE2EErrorHandling: + """Test error handling with real Vaultwarden.""" + + def test_invalid_session_handling(self): + """Test that invalid session is handled gracefully.""" + result = subprocess.run( + ["bw", "status"], + capture_output=True, + text=True, + env={**os.environ, "BW_SESSION": "invalid_session_key"}, + ) + + assert result.returncode != 0 + + def test_unlock_with_wrong_password(self): + """Test unlock with wrong password fails properly.""" + subprocess.run(["bw", "lock"], capture_output=True) + + result = subprocess.run( + [ + "bw", + "unlock", + "wrong_password", + "--raw", + ], + capture_output=True, + text=True, + env={**os.environ, "BW_SESSION": ""}, + ) + + assert result.returncode != 0 \ No newline at end of file From 1677238ddf63dfc87af46e572ad5cc4a7e2aea8e Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Mon, 20 Apr 2026 16:39:57 +0200 Subject: [PATCH 04/38] Add AGENTS.md and organizations exports --- AGENTS.md | 44 +++++++++++++++++++++++++++++++++++ Dockerfile | 11 +++++---- README.md | 29 +++++++++++++++++++++-- run.sh | 21 ++++++++--------- src/bw_client.py | 55 +++++++++++++++++++++++++++++++++++++++++++ src/form.html | 33 ++++++++++++++++++++++++-- src/init.py | 4 ++++ src/run.py | 59 +++++++++++++++++++++++++++++++++++++++++++---- tests/test_db.py | 1 + tests/test_run.py | 10 ++++++++ 10 files changed, 242 insertions(+), 25 deletions(-) create mode 100644 AGENTS.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..0c4d27c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ FROM python:3.13-alpine -ARG BW_VERSION="2025.10.0" -ARG SUPERCRONIC_VERSION="v0.2.41" -ARG SUPERCRONIC_SHA1SUM_LINUX_AMD64=f70ad28d0d739a96dc9e2087ae370c257e79b8d7 -ARG SUPERCRONIC_SHA1SUM_LINUX_ARM64=44e10e33e8d98b1d1522f6719f15fb9469786ff0 -ARG SUPERCRONIC_SHA1SUM_LINUX_ARMV7=d1e9c90160c92201233daf164088bd861b4b39a4 +ARG BW_VERSION="2026.2.0" +ARG SUPERCRONIC_VERSION="v0.2.44" +ARG SUPERCRONIC_SHA1SUM_LINUX_AMD64=6eb0a8e1e6673675dc67668c1a9b6409f79c37bc +ARG SUPERCRONIC_SHA1SUM_LINUX_ARM64=6c6cba4cde1dd4a1dd1e7fb23498cde1b57c226c +ARG SUPERCRONIC_SHA1SUM_LINUX_ARMV7=4f69f55febc78fbb10f1c0c85b907682b4da9300 ARG TARGETARCH # Install minimal required packages @@ -82,3 +82,4 @@ ENV PYTHONPATH=/app ENTRYPOINT ["/app/entrypoint.sh"] CMD ["/app/run.sh"] + diff --git a/README.md b/README.md index 9580674..891098e 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 --- @@ -84,6 +85,27 @@ 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**: 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`). + +### 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: @@ -135,8 +157,11 @@ BackVault will automatically: | `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. --- diff --git a/run.sh b/run.sh index 8a6aafb..410f65f 100644 --- a/run.sh +++ b/run.sh @@ -6,21 +6,18 @@ if [ $# -eq 0 ]; then echo "Initializing Backvault container..." INTERVAL_HOURS=${BACKUP_INTERVAL_HOURS:-12} if [ -z "${CRON_EXPRESSION:-}" ]; then - DAYS=$((INTERVAL_HOURS / 24)) - HOURS=$((INTERVAL_HOURS % 24)) - - CRON_DAY="*" - if [ "$DAYS" -gt 0 ]; then - CRON_DAY="*/$DAYS" + if ! [[ "$INTERVAL_HOURS" =~ ^[1-9][0-9]*$ ]]; then + echo "BACKUP_INTERVAL_HOURS must be a positive integer." >&2 + exit 1 fi - - if [ "$HOURS" -gt 0 ]; then - CRON_HOUR="*/$HOURS" + if (( INTERVAL_HOURS <= 23 )); then + CRON_EXPRESSION="0 */${INTERVAL_HOURS} * * *" + elif (( INTERVAL_HOURS % 24 == 0 )) && (( INTERVAL_HOURS / 24 <= 31 )); then + CRON_EXPRESSION="0 0 */$((INTERVAL_HOURS / 24)) * *" else - CRON_HOUR="0" + echo "INTERVAL_HOURS=${INTERVAL_HOURS} cannot be represented exactly with cron. Set CRON_EXPRESSION explicitly." >&2 + exit 1 fi - - CRON_EXPRESSION="0 $CRON_HOUR $CRON_DAY * *" fi UI_HOST="${SETUP_UI_HOST:-0.0.0.0}" UI_PORT="${SETUP_UI_PORT:-8080}" diff --git a/src/bw_client.py b/src/bw_client.py index 8d67a89..6bc0f5c 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", + "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/form.html b/src/form.html index 2c44d70..9b14fb3 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,30 @@ 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; + } + button { background: #f15a24; border: none; @@ -88,6 +107,16 @@

BackVault Setup

+
+ + + + +

Leave empty to export all accessible organizations.

+
diff --git a/src/init.py b/src/init.py index 522751d..de5dff0 100644 --- a/src/init.py +++ b/src/init.py @@ -46,6 +46,8 @@ def init( client_id: str = Form(...), client_secret: str = Form(...), file_password: str = Form(...), + organization_ids: str = Form(""), + org_export_mode: str = Form("single"), ): conn, cursor = db_connect(DB_PATH, PRAGMA_KEY_FILE) if not conn or not cursor: @@ -56,6 +58,8 @@ def init( 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..9927b63 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,16 @@ 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") + org_export_mode = org_export_mode_raw.decode() if org_export_mode_raw else "single" + org_ids = ( + [org.strip() for org in org_ids_raw.decode().split(",") if org.strip()] + if org_ids_raw + else [] + ) + server = require_env("BW_SERVER") if ( re.match( @@ -90,21 +101,61 @@ def main(): # 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 + + # Export personal vault + if org_export_mode == "multiple": + personal_file = os.path.join(backup_dir, f"backup_{timestamp}_personal.enc") + elif has_orgs: + personal_file = os.path.join(backup_dir, f"backup_{timestamp}_orgs.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 + if org_export_mode == "single" and has_orgs: + all_org_data = {} + for org_id in org_ids: + org_data = source.export_organization_raw(org_id) + org_name = org_data.get("name", org_id) + all_org_data[org_id] = org_data + logger.info(f"Fetched org data: {org_name} ({org_id})") + + if encryption_mode == "raw": + 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) + elif encryption_mode == "bitwarden": + for org_id in org_ids: + org_file = os.path.join(backup_dir, f"backup_{timestamp}_orgs.enc") + source.export_organization_bitwarden(org_file, file_pw, org_id) + logger.info(f"Organization export completed to {org_file}.") + + elif org_export_mode == "multiple" and has_orgs: + for org_id in org_ids: + org_file = os.path.join( + backup_dir, f"backup_{timestamp}_org-{org_id}.enc" + ) + 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}") finally: source.logout() logger.info("Successfully logged out.") 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_run.py b/tests/test_run.py index 41e5646..77e0f46 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -31,6 +31,8 @@ def test_main_bitwarden_encryption( "test_client_secret", "test_master_pw", "test_file_pw", + b"", # organization_ids (empty) + b"single", # org_export_mode ] mock_client_instance = mock_bw_client.return_value mock_sprun.return_value = CompletedProcess( @@ -79,6 +81,8 @@ 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", + b"", # organization_ids (empty) + b"single", # org_export_mode ] mock_client_instance = mock_bw_client.return_value mock_sprun.return_value = CompletedProcess( @@ -126,6 +130,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", + b"", # organization_ids (empty) + b"single", # org_export_mode ] mock_client_instance = mock_bw_client.return_value @@ -158,6 +164,8 @@ def test_main_login_fails(mock_bw_client, mock_get_key, mock_db_connect): "test_client_secret", "test_master_pw", "test_file_pw", + b"", # organization_ids (empty) + b"single", # org_export_mode ] mock_client_instance = mock_bw_client.return_value mock_client_instance.login.side_effect = Exception("Login failed") @@ -191,6 +199,8 @@ def test_main_unlock_fails(mock_bw_client, mock_get_key, mock_db_connect): "test_client_secret", "test_master_pw", "test_file_pw", + b"", # organization_ids (empty) + b"single", # org_export_mode ] mock_client_instance = mock_bw_client.return_value mock_client_instance.unlock.side_effect = Exception("Unlock failed") From abbe3553e75a25abbf04bad006132679f24e2665 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Mon, 20 Apr 2026 17:00:40 +0200 Subject: [PATCH 05/38] Enhance CI workflow and improve code documentation - Update CI configuration to include a new sanitized image tag for testing. - Refine HTML form hint for clarity on organization export behavior. - Modify run.py to better handle organization ID exports, logging configured and fetched organizations. - Improve test readability and structure in test_bw_client.py and test_cli_integration.py. - Add spacing for better readability in test_e2e.py. --- .github/workflows/ci.yml | 5 +++-- src/form.html | 2 +- src/run.py | 25 +++++++++++++++++-------- tests/test_bw_client.py | 35 +++++++++++++++++++++-------------- tests/test_cli_integration.py | 1 + tests/test_e2e.py | 22 +++++++++++++++++++--- 6 files changed, 62 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e43bae..0f3950b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,19 +103,20 @@ jobs: load: true tags: | ${{ env.IMAGE_NAME }}:${{ matrix.platform }}-test + ${{ env.IMAGE_NAME }}:sanitized-${{ matrix.platform }}-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 }}:${{ matrix.platform }}-test \ + ${{ env.IMAGE_NAME }}:sanitized-${{ matrix.platform }}-test \ bw --version - name: Verify supercronic run: | docker run --rm --platform ${{ matrix.platform }} \ - ${{ env.IMAGE_NAME }}:${{ matrix.platform }}-test \ + ${{ env.IMAGE_NAME }}:sanitized-${{ matrix.platform }}-test \ supercronic --version - name: Verify Python diff --git a/src/form.html b/src/form.html index 9b14fb3..bf1d707 100644 --- a/src/form.html +++ b/src/form.html @@ -115,7 +115,7 @@

BackVault Setup

-

Leave empty to export all accessible organizations.

+

Leave empty to automatically export all accessible organizations.

diff --git a/src/run.py b/src/run.py index 9927b63..d080fa6 100644 --- a/src/run.py +++ b/src/run.py @@ -43,7 +43,7 @@ def main(): org_ids_raw = get_key(db_conn, "organization_ids") org_export_mode_raw = get_key(db_conn, "org_export_mode") org_export_mode = org_export_mode_raw.decode() if org_export_mode_raw else "single" - org_ids = ( + configured_org_ids = ( [org.strip() for org in org_ids_raw.decode().split(",") if org.strip()] if org_ids_raw else [] @@ -99,15 +99,28 @@ def main(): logger.error(f"Unlock failed: {e}") return + # Determine org IDs to export (use configured or fetch all) + if 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 = [] + # Generate timestamped filename timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") has_orgs = len(org_ids) > 0 # Export personal vault - if org_export_mode == "multiple": + if org_export_mode == "multiple" or has_orgs: personal_file = os.path.join(backup_dir, f"backup_{timestamp}_personal.enc") - elif has_orgs: - personal_file = os.path.join(backup_dir, f"backup_{timestamp}_orgs.enc") else: personal_file = os.path.join(backup_dir, f"backup_{timestamp}.enc") @@ -140,10 +153,6 @@ def main(): org_file = os.path.join(backup_dir, f"backup_{timestamp}_orgs.enc") with open(org_file, "wb") as f: f.write(encrypted_data) - elif encryption_mode == "bitwarden": - for org_id in org_ids: - org_file = os.path.join(backup_dir, f"backup_{timestamp}_orgs.enc") - source.export_organization_bitwarden(org_file, file_pw, org_id) logger.info(f"Organization export completed to {org_file}.") elif org_export_mode == "multiple" and has_orgs: diff --git a/tests/test_bw_client.py b/tests/test_bw_client.py index 76ba6cc..2b7542b 100644 --- a/tests/test_bw_client.py +++ b/tests/test_bw_client.py @@ -205,10 +205,12 @@ 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.stdout = json.dumps( + [ + {"id": "org1", "name": "Org 1"}, + {"id": "org2", "name": "Org 2"}, + ] + ) mock_sprun.return_value.returncode = 0 client = BitwardenClient(session="test_session") @@ -218,7 +220,10 @@ def test_list_organizations(mock_sprun): assert orgs[0]["id"] == "org1" mock_sprun.assert_called_once_with( ["bw", "list", "organizations"], - text=True, capture_output=True, check=True, env=ANY + text=True, + capture_output=True, + check=True, + env=ANY, ) @@ -234,7 +239,9 @@ def test_export_organization_bitwarden(mock_sprun): os.environ["TEST_MODE"] = "1" try: - client.export_organization_bitwarden("/tmp/backups/org.enc", "file_pw", "org123") + client.export_organization_bitwarden( + "/tmp/backups/org.enc", "file_pw", "org123" + ) except Exception: pass finally: @@ -252,14 +259,16 @@ def test_export_organization_raw_encrypted(mock_file, mock_sprun): mock_sprun.return_value = MagicMock( returncode=0, stdout=json.dumps({"items": [{"id": "1", "name": "Item 1"}]}), - stderr="" + stderr="", ) client = BitwardenClient(session="test_session") os.environ["TEST_MODE"] = "1" try: - client.export_organization_raw_encrypted("/tmp/backups/org.enc", "file_pw", "org123") + client.export_organization_raw_encrypted( + "/tmp/backups/org.enc", "file_pw", "org123" + ) except Exception: pass finally: @@ -273,10 +282,9 @@ 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.stdout = json.dumps( + {"status": "unlocked", "userId": "user123"} + ) mock_sprun.return_value.returncode = 0 client = BitwardenClient(session="test_session") @@ -284,8 +292,7 @@ def test_status(mock_sprun): assert status["status"] == "unlocked" mock_sprun.assert_called_once_with( - ["bw", "status"], - text=True, capture_output=True, check=True, env=ANY + ["bw", "status"], text=True, capture_output=True, check=True, env=ANY ) diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py index 2505bf2..2a541e6 100644 --- a/tests/test_cli_integration.py +++ b/tests/test_cli_integration.py @@ -7,6 +7,7 @@ Run with: uv run pytest tests/test_cli_integration.py -v """ + import json import os from unittest.mock import patch, MagicMock diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 4d387cb..b5500c3 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -12,6 +12,7 @@ BW_TEST_MASTER_PASSWORD=masterpassword123 \ uv run pytest tests/test_e2e.py -v """ + import json import os import subprocess @@ -31,7 +32,14 @@ def vaultwarden_container(): container_name = "backvault-test-vaultwarden" result = subprocess.run( - ["docker", "ps", "--filter", f"name={container_name}", "--format", "{{.Names}}"], + [ + "docker", + "ps", + "--filter", + f"name={container_name}", + "--format", + "{{.Names}}", + ], capture_output=True, text=True, ) @@ -275,7 +283,15 @@ def test_entrypoint_exists(self): """Verify entrypoint script is executable.""" try: result = subprocess.run( - ["docker", "run", "--rm", "backvault:latest", "ls", "-la", "/app/entrypoint.sh"], + [ + "docker", + "run", + "--rm", + "backvault:latest", + "ls", + "-la", + "/app/entrypoint.sh", + ], capture_output=True, text=True, check=True, @@ -316,4 +332,4 @@ def test_unlock_with_wrong_password(self): env={**os.environ, "BW_SESSION": ""}, ) - assert result.returncode != 0 \ No newline at end of file + assert result.returncode != 0 From 5085ab37678947572977c194b334412340c59808 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Mon, 20 Apr 2026 17:43:37 +0200 Subject: [PATCH 06/38] Fix CI and run tests, add bin to gitignore --- .github/workflows/ci.yml | 9 ++++----- .gitignore | 1 + src/run.py | 11 +++++------ 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f3950b..1154e61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,7 +102,6 @@ jobs: platforms: ${{ matrix.platform }} load: true tags: | - ${{ env.IMAGE_NAME }}:${{ matrix.platform }}-test ${{ env.IMAGE_NAME }}:sanitized-${{ matrix.platform }}-test cache-from: type=gha cache-to: type=gha,mode=max @@ -122,25 +121,25 @@ jobs: - name: Verify Python run: | docker run --rm --platform ${{ matrix.platform }} \ - ${{ env.IMAGE_NAME }}:${{ matrix.platform }}-test \ + ${{ env.IMAGE_NAME }}:sanitized-${{ matrix.platform }}-test \ python3 --version - name: Verify entrypoint exists run: | docker run --rm --platform ${{ matrix.platform }} \ - ${{ env.IMAGE_NAME }}:${{ matrix.platform }}-test \ + ${{ env.IMAGE_NAME }}:sanitized-${{ matrix.platform }}-test \ ls -la /app/entrypoint.sh - name: Verify run script exists run: | docker run --rm --platform ${{ matrix.platform }} \ - ${{ env.IMAGE_NAME }}:${{ matrix.platform }}-test \ + ${{ env.IMAGE_NAME }}:sanitized-${{ matrix.platform }}-test \ ls -la /app/run.sh - name: Verify required directories run: | docker run --rm --platform ${{ matrix.platform }} \ - ${{ env.IMAGE_NAME }}:${{ matrix.platform }}-test \ + ${{ env.IMAGE_NAME }}:sanitized-${{ matrix.platform }}-test \ ls -la /app/backups /app/db /app/logs docker-push: diff --git a/.gitignore b/.gitignore index a9de155..06adb5c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ .ruff_cache/ .venv/ +bin/ backup/ logs/ decrypt.py diff --git a/src/run.py b/src/run.py index d080fa6..7d64c57 100644 --- a/src/run.py +++ b/src/run.py @@ -147,12 +147,11 @@ def main(): all_org_data[org_id] = org_data logger.info(f"Fetched org data: {org_name} ({org_id})") - if encryption_mode == "raw": - 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) + 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: From 703f595b100109478709f3880485255958579b45 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Mon, 20 Apr 2026 17:49:15 +0200 Subject: [PATCH 07/38] fix CI --- .github/workflows/ci.yml | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1154e61..0cb06af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,50 +96,56 @@ jobs: - 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 }}:sanitized-${{ matrix.platform }}-test + ${{ 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 }}:sanitized-${{ matrix.platform }}-test \ + ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \ bw --version - name: Verify supercronic run: | docker run --rm --platform ${{ matrix.platform }} \ - ${{ env.IMAGE_NAME }}:sanitized-${{ matrix.platform }}-test \ + ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \ supercronic --version - name: Verify Python run: | docker run --rm --platform ${{ matrix.platform }} \ - ${{ env.IMAGE_NAME }}:sanitized-${{ matrix.platform }}-test \ + ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \ python3 --version - name: Verify entrypoint exists run: | docker run --rm --platform ${{ matrix.platform }} \ - ${{ env.IMAGE_NAME }}:sanitized-${{ matrix.platform }}-test \ + ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \ ls -la /app/entrypoint.sh - name: Verify run script exists run: | docker run --rm --platform ${{ matrix.platform }} \ - ${{ env.IMAGE_NAME }}:sanitized-${{ matrix.platform }}-test \ + ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \ ls -la /app/run.sh - name: Verify required directories run: | docker run --rm --platform ${{ matrix.platform }} \ - ${{ env.IMAGE_NAME }}:sanitized-${{ matrix.platform }}-test \ + ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \ ls -la /app/backups /app/db /app/logs docker-push: From 48bd975f0aac288d83b5df45f640a15063c28ebe Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Mon, 20 Apr 2026 17:57:37 +0200 Subject: [PATCH 08/38] reintroduce raw and bitwarden on org encryption modes --- src/run.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/run.py b/src/run.py index 7d64c57..9a86f21 100644 --- a/src/run.py +++ b/src/run.py @@ -140,19 +140,24 @@ def main(): # Export organizations if org_export_mode == "single" and has_orgs: - all_org_data = {} - for org_id in org_ids: - org_data = source.export_organization_raw(org_id) - org_name = org_data.get("name", org_id) - all_org_data[org_id] = org_data - logger.info(f"Fetched org data: {org_name} ({org_id})") - - 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}.") + if encryption_mode == "raw": + all_org_data = {} + for org_id in org_ids: + org_data = source.export_organization_raw(org_id) + all_org_data[org_id] = org_data + + 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 encryption_mode == "bitwarden": + for org_id in org_ids: + org_file = os.path.join(backup_dir, f"backup_{timestamp}_org-{org_id}.enc") + source.export_organization_bitwarden(org_file, file_pw, org_id) + logger.info(f"Organization export completed: {org_file}") elif org_export_mode == "multiple" and has_orgs: for org_id in org_ids: From 66ee8529cf282e1b747b6fa67a640ae7c50a5d12 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Mon, 20 Apr 2026 18:30:56 +0200 Subject: [PATCH 09/38] Enhance CI workflows, add Bitwarden CLI installation, and improve HTML form accessibility --- .github/workflows/ci.yml | 17 ++++---- .github/workflows/e2e.yml | 4 ++ src/form.html | 8 ++-- src/run.py | 14 ++++++- tests/README.md | 21 +++++----- tests/docker_test.sh | 8 +++- tests/test_bw_client.py | 36 +++++++--------- tests/test_cli_integration.py | 78 +++++++++++++++++------------------ tests/test_e2e.py | 21 +++++++++- 9 files changed, 119 insertions(+), 88 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cb06af..5556b9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI permissions: contents: read pull-requests: write + packages: write on: push: @@ -130,27 +131,27 @@ jobs: ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \ python3 --version - - name: Verify entrypoint exists + - name: Verify entrypoint is executable run: | docker run --rm --platform ${{ matrix.platform }} \ ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \ - ls -la /app/entrypoint.sh + test -x /app/entrypoint.sh - - name: Verify run script exists + - name: Verify run script is executable run: | docker run --rm --platform ${{ matrix.platform }} \ ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \ - ls -la /app/run.sh + test -x /app/run.sh - - name: Verify required directories + - name: Verify required directories exist run: | docker run --rm --platform ${{ matrix.platform }} \ ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \ - ls -la /app/backups /app/db /app/logs + test -d /app/backups && test -d /app/db && test -d /app/logs docker-push: name: Docker Push - needs: docker-build + needs: [docker-build, lint, unit-tests] runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: @@ -182,6 +183,8 @@ jobs: 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.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:${{ github.sha }} ${{ env.IMAGE_NAME }}:sha-${{ github.sha }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 84e5429..a0d43a5 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -61,6 +61,10 @@ jobs: run: | curl -sf http://localhost:8080/health || exit 1 + - name: Install Bitwarden CLI + run: | + npm install -g @bitwarden/cli + - name: Configure Bitwarden CLI run: | bw config server ${{ env.VAULTWARDEN_URL }} diff --git a/src/form.html b/src/form.html index bf1d707..302ed2b 100644 --- a/src/form.html +++ b/src/form.html @@ -108,13 +108,13 @@

BackVault Setup

- - - - + +

Leave empty to automatically export all accessible organizations.

diff --git a/src/run.py b/src/run.py index 9a86f21..273c61e 100644 --- a/src/run.py +++ b/src/run.py @@ -42,7 +42,14 @@ def main(): # Organization configuration org_ids_raw = get_key(db_conn, "organization_ids") org_export_mode_raw = get_key(db_conn, "org_export_mode") - org_export_mode = org_export_mode_raw.decode() if org_export_mode_raw else "single" + raw_value = org_export_mode_raw.decode() if org_export_mode_raw else b"single" + if isinstance(raw_value, str): + raw_value = raw_value.encode() + org_export_mode = raw_value if raw_value in (b"single", b"multiple") else b"single" + if raw_value != org_export_mode: + logger.warning( + f"Invalid org_export_mode '{raw_value.decode()}', defaulting to 'single'" + ) configured_org_ids = ( [org.strip() for org in org_ids_raw.decode().split(",") if org.strip()] if org_ids_raw @@ -155,9 +162,12 @@ def main(): elif encryption_mode == "bitwarden": for org_id in org_ids: - org_file = os.path.join(backup_dir, f"backup_{timestamp}_org-{org_id}.enc") + org_file = os.path.join( + backup_dir, f"backup_{timestamp}_org-{org_id}.enc" + ) source.export_organization_bitwarden(org_file, file_pw, org_id) logger.info(f"Organization export completed: {org_file}") + logger.info("Note: Bitwarden native format requires per-org files.") elif org_export_mode == "multiple" and has_orgs: for org_id in org_ids: diff --git a/tests/README.md b/tests/README.md index 97d3bca..c8d898f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -4,7 +4,7 @@ This document describes how to run tests for Backvault. ## Prerequisites -- Python 3.13+ +- Python 3.12+ - [uv](https://github.com/astral-sh/uv) package manager - Docker (for Docker/E2E tests) - Bitwarden CLI (`bw`) @@ -47,13 +47,13 @@ docker run -d --name vaultwarden-test \ vaultwarden/server:latest ``` -2. Wait for it to be ready (~10 seconds) +## Wait for it to be ready (~10 seconds) ```bash sleep 10 ``` -3. Set environment variables: +## Set environment variables ```bash export VAULTWARDEN_URL=http://localhost:8080 @@ -62,7 +62,7 @@ export BW_TEST_PASSWORD=your-test-password export BW_TEST_MASTER_PASSWORD=your-master-password ``` -4. Run E2E tests: +## Run E2E tests ```bash uv run pytest tests/test_e2e.py -v @@ -92,13 +92,14 @@ bw status ### Multi-Arch Build ```bash -# Build for all platforms -docker buildx build --platform=linux/amd64,linux/arm64,linux/arm/v7 --load . +# 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 -# Test each platform -docker run --rm --platform linux/amd64 bw --version -docker run --rm --platform linux/arm64 bw --version -docker run --rm --platform linux/arm/v7 bw --version +# Or use the provided test script +./tests/docker_test.sh ``` ### Run Full Docker Test Suite diff --git a/tests/docker_test.sh b/tests/docker_test.sh index ae9ea58..3995bf6 100755 --- a/tests/docker_test.sh +++ b/tests/docker_test.sh @@ -22,14 +22,18 @@ if ! docker buildx version &> /dev/null; then 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..." - docker buildx rm backvault-builder 2>/dev/null || true + if [ "$CREATED_BUILDER" = "1" ]; then + docker buildx rm backvault-builder 2>/dev/null || true + fi } trap cleanup EXIT @@ -82,7 +86,7 @@ for platform in "${PLATFORMS[@]}"; do echo "Testing required directories exist..." docker run --rm --platform "$platform" \ "${IMAGE_NAME}:${platform_tag}-test" \ - test -d /app/backups /app/db /app/logs + sh -c 'test -d /app/backups && test -d /app/db && test -d /app/logs' echo "Testing environment variables..." docker run --rm --platform "$platform" \ diff --git a/tests/test_bw_client.py b/tests/test_bw_client.py index 2b7542b..79a572a 100644 --- a/tests/test_bw_client.py +++ b/tests/test_bw_client.py @@ -1,5 +1,4 @@ import json -import os import pytest from unittest.mock import patch, ANY, MagicMock from src.bw_client import BitwardenClient, BitwardenError @@ -228,34 +227,32 @@ def test_list_organizations(mock_sprun): @patch("src.bw_client.sprun") -def test_export_organization_bitwarden(mock_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") - os.environ["TEST_MODE"] = "1" - try: - client.export_organization_bitwarden( - "/tmp/backups/org.enc", "file_pw", "org123" - ) - except Exception: - pass - finally: - del os.environ["TEST_MODE"] + 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): +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"}]}), @@ -264,17 +261,14 @@ def test_export_organization_raw_encrypted(mock_file, mock_sprun): client = BitwardenClient(session="test_session") - os.environ["TEST_MODE"] = "1" - try: - client.export_organization_raw_encrypted( - "/tmp/backups/org.enc", "file_pw", "org123" - ) - except Exception: - pass - finally: - del os.environ["TEST_MODE"] + 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") diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py index 2a541e6..3332b93 100644 --- a/tests/test_cli_integration.py +++ b/tests/test_cli_integration.py @@ -9,7 +9,6 @@ """ import json -import os from unittest.mock import patch, MagicMock from src.bw_client import BitwardenClient @@ -18,58 +17,55 @@ class TestCLIWorkflow: """Tests for complete CLI workflows.""" @patch("src.bw_client.sprun") - def test_full_backup_workflow_bitwarden_mode(self, mock_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. """ - os.environ["TEST_MODE"] = "1" - try: - 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", - ) + 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.login() + client.unlock("master_password") - client.export_bitwarden_encrypted("/tmp/backups/personal.enc", "file_pw") + 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"] - ) + 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() + client.logout() - assert mock_sprun.call_count == 7 - calls = [str(c) for c in mock_sprun.call_args_list] + 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) - finally: - del os.environ["TEST_MODE"] + 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: diff --git a/tests/test_e2e.py b/tests/test_e2e.py index b5500c3..8711cf7 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -49,6 +49,16 @@ def vaultwarden_container(): yield container_name return + result = subprocess.run( + ["docker", "ps", "--filter", "publish=8080", "--format", "{{.Names}}"], + capture_output=True, + text=True, + ) + if result.stdout.strip(): + print(f"Using existing container on port 8080: {result.stdout.strip()}") + yield result.stdout.strip() + return + print(f"Starting Vaultwarden container: {container_name}") subprocess.run( [ @@ -216,10 +226,19 @@ def test_backup_personal_vault_raw_mode(self, bw_session, tmp_path): """Test backing up personal vault with raw (AES-256-GCM) encryption.""" from src.bw_client import BitwardenClient + old_test_mode = os.environ.get("TEST_MODE") + os.environ["TEST_MODE"] = "1" + client = BitwardenClient(session=bw_session, server=VAULTWARDEN_URL) backup_file = tmp_path / "personal_raw.enc" - client.export_raw_encrypted(str(backup_file), "backup_password") + try: + client.export_raw_encrypted(str(backup_file), "backup_password") + finally: + if old_test_mode is not None: + os.environ["TEST_MODE"] = old_test_mode + else: + os.environ.pop("TEST_MODE", None) assert backup_file.exists() assert backup_file.stat().st_size > 0 From 42a4377e3ea5c95de62e21921c54450f466df15a Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Tue, 21 Apr 2026 09:57:20 +0200 Subject: [PATCH 10/38] Refactor CI and E2E workflows, improve Docker image testing, and enhance Bitwarden CLI environment isolation --- .github/workflows/ci.yml | 2 +- .github/workflows/e2e.yml | 2 +- src/run.py | 12 +++++------ tests/docker_test.sh | 3 ++- tests/test_e2e.py | 45 ++++++++++++++++++++++++++------------- 5 files changed, 39 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5556b9f..8292fb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,7 @@ jobs: id: platform-slug run: | PLATFORM="${{ matrix.platform }}" - echo "slug=${PLATFORM//\//-}" >> $GITHUB_OUTPUT + echo "slug=${PLATFORM//\//-}" >> "$GITHUB_OUTPUT" - name: Build Docker image uses: docker/build-push-action@v5 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a0d43a5..174f2d4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -39,7 +39,7 @@ jobs: - name: Start Vaultwarden run: | docker run -d \ - --name vaultwarden-test \ + --name backvault-e2e-vaultwarden \ -p 8080:80 \ -e SIGNUPS_ALLOWED=true \ -e ADMIN_TOKEN=admin_secret_token_for_testing \ diff --git a/src/run.py b/src/run.py index 273c61e..a015477 100644 --- a/src/run.py +++ b/src/run.py @@ -42,14 +42,12 @@ def main(): # 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.decode() if org_export_mode_raw else b"single" - if isinstance(raw_value, str): - raw_value = raw_value.encode() - org_export_mode = raw_value if raw_value in (b"single", b"multiple") else b"single" + raw_value = org_export_mode_raw.decode("utf-8") if org_export_mode_raw else "single" + if isinstance(raw_value, bytes): + raw_value = raw_value.decode("utf-8") + org_export_mode = raw_value if raw_value in ("single", "multiple") else "single" if raw_value != org_export_mode: - logger.warning( - f"Invalid org_export_mode '{raw_value.decode()}', defaulting to 'single'" - ) + logger.warning(f"Invalid org_export_mode '{raw_value}', defaulting to 'single'") configured_org_ids = ( [org.strip() for org in org_ids_raw.decode().split(",") if org.strip()] if org_ids_raw diff --git a/tests/docker_test.sh b/tests/docker_test.sh index 3995bf6..31ff8a9 100755 --- a/tests/docker_test.sh +++ b/tests/docker_test.sh @@ -4,7 +4,8 @@ set -e -IMAGE_NAME="${IMAGE_NAME:-ghcr.io/$(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]')}" +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 ===" diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 8711cf7..88ea897 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -104,12 +104,19 @@ def vaultwarden_container(): subprocess.run(["docker", "rm", "-f", container_name], capture_output=True) -@pytest.fixture(scope="module") -def test_user(vaultwarden_container): +@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.""" - subprocess.run(["bw", "config", "server", VAULTWARDEN_URL], check=True) + subprocess.run(["bw", "config", "server", VAULTWARDEN_URL], check=True, env=bw_env) - subprocess.run(["bw", "logout"], capture_output=True) + subprocess.run(["bw", "logout"], capture_output=True, env=bw_env) result = subprocess.run( [ @@ -123,6 +130,7 @@ def test_user(vaultwarden_container): ], capture_output=True, text=True, + env=bw_env, ) if result.returncode != 0 and "already exists" not in result.stderr.lower(): @@ -132,15 +140,16 @@ def test_user(vaultwarden_container): print(f"Register output: {result.stdout}") print(f"Register error: {result.stderr}") - yield {"email": TEST_EMAIL, "password": TEST_PASSWORD} + yield {"email": TEST_EMAIL, "password": TEST_PASSWORD, "bw_env": bw_env} -@pytest.fixture(scope="module") +@pytest.fixture(scope="function") def bw_session(test_user): """Create Bitwarden CLI session by logging in.""" - subprocess.run(["bw", "config", "server", VAULTWARDEN_URL], check=True) + 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) + subprocess.run(["bw", "logout"], capture_output=True, env=bw_env) result = subprocess.run( [ @@ -153,7 +162,7 @@ def bw_session(test_user): ], capture_output=True, text=True, - env={**os.environ, "BW_SESSION": ""}, + env={**bw_env, "BW_SESSION": ""}, ) if result.returncode != 0: @@ -173,21 +182,21 @@ def bw_session(test_user): ], capture_output=True, text=True, - env={**os.environ, "BW_SESSION": session}, + env={**bw_env, "BW_SESSION": session}, ) if result.returncode != 0: pytest.skip(f"Cannot unlock vault: {result.stderr}") session = result.stdout.strip() - os.environ["BW_SESSION"] = session + bw_env["BW_SESSION"] = session yield session - subprocess.run(["bw", "lock"], capture_output=True) - subprocess.run(["bw", "logout"], capture_output=True) - if "BW_SESSION" in os.environ: - del os.environ["BW_SESSION"] + 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: @@ -337,6 +346,7 @@ def test_invalid_session_handling(self): def test_unlock_with_wrong_password(self): """Test unlock with wrong password fails properly.""" + orig_session = os.environ.get("BW_SESSION") subprocess.run(["bw", "lock"], capture_output=True) result = subprocess.run( @@ -351,4 +361,9 @@ def test_unlock_with_wrong_password(self): env={**os.environ, "BW_SESSION": ""}, ) + if orig_session is not None: + os.environ["BW_SESSION"] = orig_session + elif "BW_SESSION" in os.environ: + del os.environ["BW_SESSION"] + assert result.returncode != 0 From 3630b9e8dd9061e167a4799ae926120afad81a4b Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Tue, 21 Apr 2026 10:50:41 +0200 Subject: [PATCH 11/38] Enhance E2E workflow: use secrets for sensitive data, set up Python and Node.js, and install system dependencies --- .github/workflows/e2e.yml | 25 ++++++++--- .github/workflows/opencode.yml | 33 +++++++++++++++ src/run.py | 16 ++++--- tests/test_e2e.py | 77 ++++++++++++++++------------------ 4 files changed, 96 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/opencode.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 174f2d4..4b64bcf 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -42,7 +42,7 @@ jobs: --name backvault-e2e-vaultwarden \ -p 8080:80 \ -e SIGNUPS_ALLOWED=true \ - -e ADMIN_TOKEN=admin_secret_token_for_testing \ + -e ADMIN_TOKEN=${{ secrets.ADMIN_TOKEN }} \ vaultwarden/server:latest - name: Wait for Vaultwarden to start @@ -61,6 +61,19 @@ jobs: run: | curl -sf http://localhost:8080/health || exit 1 + - 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: apt-get update && apt-get install -y libsqlcipher-dev + - name: Install Bitwarden CLI run: | npm install -g @bitwarden/cli @@ -77,16 +90,16 @@ jobs: - name: Run E2E tests run: | VAULTWARDEN_URL=${{ env.VAULTWARDEN_URL }} \ - BW_TEST_EMAIL=e2e@test.com \ - BW_TEST_PASSWORD=Test123! \ - BW_TEST_MASTER_PASSWORD=Master123! \ + BW_TEST_EMAIL=${{ secrets.BW_TEST_EMAIL }} \ + BW_TEST_PASSWORD=${{ secrets.BW_TEST_PASSWORD }} \ + BW_TEST_MASTER_PASSWORD=${{ secrets.BW_TEST_MASTER_PASSWORD }} \ uv run pytest tests/test_e2e.py -v --timeout=300 - name: Cleanup Vaultwarden if: always() run: | - docker stop vaultwarden-test || true - docker rm vaultwarden-test || true + docker stop backvault-e2e-vaultwarden || true + docker rm backvault-e2e-vaultwarden || true - name: Cleanup Docker buildx if: always() diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml new file mode 100644 index 0000000..07d33d7 --- /dev/null +++ b/.github/workflows/opencode.yml @@ -0,0 +1,33 @@ +name: opencode + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + opencode: + if: | + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: read + issues: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Run opencode + uses: anomalyco/opencode/github@latest + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + model: opencode/big-pickle \ No newline at end of file diff --git a/src/run.py b/src/run.py index a015477..f5140a5 100644 --- a/src/run.py +++ b/src/run.py @@ -43,8 +43,6 @@ def main(): 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.decode("utf-8") if org_export_mode_raw else "single" - if isinstance(raw_value, bytes): - raw_value = raw_value.decode("utf-8") org_export_mode = raw_value if raw_value in ("single", "multiple") else "single" if raw_value != org_export_mode: logger.warning(f"Invalid org_export_mode '{raw_value}', defaulting to 'single'") @@ -159,13 +157,13 @@ def main(): logger.info(f"Organization export completed to {org_file}.") elif encryption_mode == "bitwarden": - for org_id in org_ids: - org_file = os.path.join( - backup_dir, f"backup_{timestamp}_org-{org_id}.enc" - ) - source.export_organization_bitwarden(org_file, file_pw, org_id) - logger.info(f"Organization export completed: {org_file}") - logger.info("Note: Bitwarden native format requires per-org files.") + logger.error( + f"org_export_mode='single' is not supported with encryption_mode='bitwarden'. " + f"Use org_export_mode='multiple' or switch to encryption_mode='raw'. " + f"The 'single' mode requires export_organization_bitwarden to produce " + f"backup_{timestamp}_orgs.enc which is not supported by Bitwarden CLI." + ) + return elif org_export_mode == "multiple" and has_orgs: for org_id in org_ids: diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 88ea897..40eb6ff 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -55,8 +55,10 @@ def vaultwarden_container(): text=True, ) if result.stdout.strip(): - print(f"Using existing container on port 8080: {result.stdout.strip()}") - yield 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 8080: {container_name}") + yield container_name return print(f"Starting Vaultwarden container: {container_name}") @@ -191,7 +193,7 @@ def bw_session(test_user): session = result.stdout.strip() bw_env["BW_SESSION"] = session - yield 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) @@ -204,14 +206,14 @@ class TestE2EBackup: def test_cli_can_login_and_unlock(self, bw_session): """Verify CLI can login and unlock vault.""" - assert bw_session - assert len(bw_session) > 0 + assert bw_session["session"] + assert len(bw_session["session"]) > 0 result = subprocess.run( ["bw", "status"], capture_output=True, text=True, - env={**os.environ, "BW_SESSION": bw_session}, + env={**bw_session["bw_env"], "BW_SESSION": bw_session["session"]}, ) assert result.returncode == 0 @@ -224,7 +226,7 @@ def test_list_items(self, bw_session): ["bw", "list", "items"], capture_output=True, text=True, - env={**os.environ, "BW_SESSION": bw_session}, + env={**bw_session["bw_env"], "BW_SESSION": bw_session["session"]}, ) assert result.returncode == 0 @@ -236,9 +238,13 @@ def test_backup_personal_vault_raw_mode(self, bw_session, tmp_path): from src.bw_client import BitwardenClient old_test_mode = os.environ.get("TEST_MODE") + old_appdata = os.environ.get("BITWARDENCLI_APPDATA_DIR") os.environ["TEST_MODE"] = "1" + os.environ["BITWARDENCLI_APPDATA_DIR"] = bw_session["bw_env"][ + "BITWARDENCLI_APPDATA_DIR" + ] - client = BitwardenClient(session=bw_session, server=VAULTWARDEN_URL) + client = BitwardenClient(session=bw_session["session"], server=VAULTWARDEN_URL) backup_file = tmp_path / "personal_raw.enc" try: @@ -248,6 +254,10 @@ def test_backup_personal_vault_raw_mode(self, bw_session, tmp_path): os.environ["TEST_MODE"] = old_test_mode else: os.environ.pop("TEST_MODE", None) + if old_appdata is not None: + os.environ["BITWARDENCLI_APPDATA_DIR"] = old_appdata + else: + os.environ.pop("BITWARDENCLI_APPDATA_DIR", None) assert backup_file.exists() assert backup_file.stat().st_size > 0 @@ -261,7 +271,7 @@ def test_list_organizations(self, bw_session): ["bw", "list", "organizations"], capture_output=True, text=True, - env={**os.environ, "BW_SESSION": bw_session}, + env={**bw_session["bw_env"], "BW_SESSION": bw_session["session"]}, ) assert result.returncode == 0 @@ -274,7 +284,7 @@ def test_status_returns_user_info(self, bw_session): ["bw", "status"], capture_output=True, text=True, - env={**os.environ, "BW_SESSION": bw_session}, + env={**bw_session["bw_env"], "BW_SESSION": bw_session["session"]}, ) assert result.returncode == 0 @@ -288,24 +298,22 @@ class TestE2EDocker: def test_docker_image_has_required_binaries(self): """Verify Docker image has all required binaries.""" - required_binaries = ["bw", "supercronic", "python3"] + result = subprocess.run( + ["docker", "image", "inspect", "backvault:latest"], + capture_output=True, + ) + if result.returncode != 0: + pytest.skip("Image not built yet: backvault:latest") + + required_binaries = ["bw", "supercronic", "python3", "sqlcipher"] for binary in required_binaries: result = subprocess.run( - ["docker", "run", "--rm", "vaultwarden/server:latest", "which", binary], + ["docker", "run", "--rm", "backvault:latest", "which", binary], capture_output=True, + check=True, ) - if result.returncode == 0 and binary == "bw": - print(f"Found {binary} in vaultwarden image") - continue - try: - subprocess.run( - ["docker", "run", "--rm", "backvault:latest", "which", binary], - capture_output=True, - check=True, - ) - except subprocess.CalledProcessError: - pytest.skip(f"Image not built yet: {binary}") + assert result.returncode == 0, f"Binary {binary} not found in image" def test_entrypoint_exists(self): """Verify entrypoint script is executable.""" @@ -333,37 +341,26 @@ def test_entrypoint_exists(self): class TestE2EErrorHandling: """Test error handling with real Vaultwarden.""" - def test_invalid_session_handling(self): + def test_invalid_session_handling(self, bw_env): """Test that invalid session is handled gracefully.""" result = subprocess.run( ["bw", "status"], capture_output=True, text=True, - env={**os.environ, "BW_SESSION": "invalid_session_key"}, + env={**bw_env, "BW_SESSION": "invalid_session_key"}, ) assert result.returncode != 0 - def test_unlock_with_wrong_password(self): + def test_unlock_with_wrong_password(self, bw_env): """Test unlock with wrong password fails properly.""" - orig_session = os.environ.get("BW_SESSION") - subprocess.run(["bw", "lock"], capture_output=True) + subprocess.run(["bw", "lock"], capture_output=True, env=bw_env) result = subprocess.run( - [ - "bw", - "unlock", - "wrong_password", - "--raw", - ], + ["bw", "unlock", "wrong_password", "--raw"], capture_output=True, text=True, - env={**os.environ, "BW_SESSION": ""}, + env={**bw_env, "BW_SESSION": ""}, ) - if orig_session is not None: - os.environ["BW_SESSION"] = orig_session - elif "BW_SESSION" in os.environ: - del os.environ["BW_SESSION"] - assert result.returncode != 0 From 29f4c6872b64eab4512a92870a30ef86ee0e9b13 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Tue, 21 Apr 2026 10:52:43 +0200 Subject: [PATCH 12/38] add missing dependencies in CI lint --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8292fb6..59b15a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,9 @@ jobs: with: python-version: '3.13' + - name: Install system dependencies + run: apt-get update && apt-get install -y libsqlcipher-dev + - name: Install uv run: pip install uv From 2192b62fedd3ea47943a2b795c93100ce7834ab3 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Tue, 21 Apr 2026 10:53:24 +0200 Subject: [PATCH 13/38] fix dependencies in CI lint --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59b15a7..e3abce2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,9 @@ jobs: python-version: '3.13' - name: Install system dependencies - run: apt-get update && apt-get install -y libsqlcipher-dev + run: | + sudo apt-get update + sudo apt-get install -y gcc libsqlite3-dev libsqlcipher-dev libssl-dev - name: Install uv run: pip install uv From d749cecb4868eb482d322c7a84e34e4d2ff99d8d Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Tue, 21 Apr 2026 10:59:58 +0200 Subject: [PATCH 14/38] fix ci errors and add dependencies --- .github/workflows/e2e.yml | 2 +- Dockerfile | 2 +- entrypoint.sh | 6 +-- pyproject.toml | 1 + uv.lock | 100 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 5 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4b64bcf..366652a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -72,7 +72,7 @@ jobs: node-version: '20' - name: Install system dependencies - run: apt-get update && apt-get install -y libsqlcipher-dev + run: sudo apt-get update && sudo apt-get install -y libsqlcipher-dev - name: Install Bitwarden CLI run: | diff --git a/Dockerfile b/Dockerfile index c7af163..b69d42d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ RUN apk update && apk add --no-cache \ RUN apk upgrade -a -RUN addgroup -S appgroup && adduser -S appuser -G appgroup +RUN addgroup -S appgroup 2>/dev/null || true && adduser -S appuser -G appgroup 2>/dev/null || true # Install Bitwarden CLI RUN set -eux; \ diff --git a/entrypoint.sh b/entrypoint.sh index 8c4136b..9d9f32f 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -7,9 +7,9 @@ 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 + addgroup -g "$PGID" appgroup 2>/dev/null || true + addgroup appuser appgroup 2>/dev/null || true fi # Modify user if PUID provided 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/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" From b53d33efd8b9cfa08e680e6d99b5d2f366d97bea Mon Sep 17 00:00:00 2001 From: Matheus Date: Tue, 21 Apr 2026 11:02:26 +0200 Subject: [PATCH 15/38] Update .github/workflows/ci.yml Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59b15a7..dc71b85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: python-version: '3.13' - name: Install system dependencies - run: apt-get update && apt-get install -y libsqlcipher-dev + run: sudo apt-get update && sudo apt-get install -y libsqlcipher-dev - name: Install uv run: pip install uv From a7756581296a0f45544e0ea1e4840e1a1ef51f11 Mon Sep 17 00:00:00 2001 From: Matheus Date: Tue, 21 Apr 2026 11:09:19 +0200 Subject: [PATCH 16/38] Update .github/workflows/e2e.yml Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .github/workflows/e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4b64bcf..366652a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -72,7 +72,7 @@ jobs: node-version: '20' - name: Install system dependencies - run: apt-get update && apt-get install -y libsqlcipher-dev + run: sudo apt-get update && sudo apt-get install -y libsqlcipher-dev - name: Install Bitwarden CLI run: | From d0a6fc0138b164bea0ed681cb6fdfc2d989a21d6 Mon Sep 17 00:00:00 2001 From: Matheus Date: Tue, 21 Apr 2026 11:36:09 +0200 Subject: [PATCH 17/38] Update .github/workflows/ci.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc71b85..594ac07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,12 +67,11 @@ jobs: - name: Upload coverage uses: actions/upload-artifact@v4 - if: always() && (success() || failure()) + if: success() || failure() with: name: coverage path: coverage.xml retention-days: 7 - - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: From 2e8e53b3ef250632eb0e4000c6ea5f1ad92cf635 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Tue, 21 Apr 2026 11:59:29 +0200 Subject: [PATCH 18/38] Update opencode workflow permissions and enhance README and form UI for organization export mode --- .github/workflows/opencode.yml | 4 ++-- README.md | 15 ++++++++++++--- src/form.html | 13 +++++++++++-- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 07d33d7..d486067 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -16,8 +16,8 @@ jobs: runs-on: ubuntu-latest permissions: id-token: write - contents: read - pull-requests: read + contents: write + pull-requests: write issues: read steps: - name: Checkout repository diff --git a/README.md b/README.md index 34b035f..ab25664 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,19 @@ BackVault supports backing up **multiple organizations** from your Bitwarden acc 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**: Each org exported to its own file (`backup_{timestamp}_org-{org-id}.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: @@ -159,7 +168,7 @@ 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` | @@ -167,7 +176,7 @@ BackVault will automatically: | `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. +> **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/src/form.html b/src/form.html index 302ed2b..62fa7ea 100644 --- a/src/form.html +++ b/src/form.html @@ -77,6 +77,13 @@ margin-bottom: 18px; } + .org-warning { + font-size: 12px; + color: #ff6b6b; + margin-top: -14px; + margin-bottom: 18px; + } + button { background: #f15a24; border: none; @@ -110,9 +117,11 @@

BackVault Setup

+ +

Leave empty to automatically export all accessible organizations.

From aa8d737eb51076e5a4fc5cc1caa5adc39ec997a1 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Tue, 21 Apr 2026 13:50:44 +0200 Subject: [PATCH 19/38] Refactor CI and E2E workflows, enhance opencode conditions, and update organization export logic --- .github/workflows/ci.yml | 7 +++---- .github/workflows/e2e.yml | 27 +++++++++++++++++-------- .github/workflows/opencode.yml | 15 ++++++++------ .gitmodules | 3 +++ .skills/greptile | 1 + src/db.py | 7 +++++-- src/run.py | 37 ++++++++++++++++++++++------------ tests/test_e2e.py | 26 +++++++----------------- tests/test_run.py | 20 +++++++++--------- 9 files changed, 81 insertions(+), 62 deletions(-) create mode 100644 .gitmodules create mode 160000 .skills/greptile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 594ac07..d5d2899 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,6 @@ name: CI permissions: contents: read - pull-requests: write - packages: write on: push: @@ -67,7 +65,7 @@ jobs: - name: Upload coverage uses: actions/upload-artifact@v4 - if: success() || failure() + if: always() with: name: coverage path: coverage.xml @@ -156,6 +154,8 @@ jobs: needs: [docker-build, lint, unit-tests] runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + packages: write steps: - uses: actions/checkout@v4 @@ -188,7 +188,6 @@ jobs: ghcr.io/${{ env.IMAGE_NAME }}:latest ghcr.io/${{ env.IMAGE_NAME }}:sha-${{ github.sha }} ${{ env.IMAGE_NAME }}:latest - ${{ env.IMAGE_NAME }}:${{ github.sha }} ${{ env.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 index 366652a..545f013 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -8,6 +8,12 @@ on: workflows: [CI] types: [completed] branches: [main] + pull_request: + paths: + - 'tests/**' + - '.github/workflows/e2e.yml' + - 'src/**' + workflow_dispatch: env: IMAGE_NAME: ${{ github.repository }} @@ -16,7 +22,10 @@ env: jobs: e2e: name: E2E Tests - if: ${{ github.event.workflow_run.conclusion == 'success' }} + if: > + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') || + github.event_name == 'pull_request' || + github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -32,7 +41,9 @@ jobs: with: platforms: linux/amd64 load: true - tags: ${{ env.IMAGE_NAME }}:e2e-test + tags: | + ${{ env.IMAGE_NAME }}:e2e-test + backvault:latest cache-from: type=gha cache-to: type=gha,mode=max @@ -88,12 +99,12 @@ jobs: uv sync --dev - name: Run E2E tests - run: | - 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 }} \ - uv run pytest tests/test_e2e.py -v --timeout=300 + 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 }} + run: uv run pytest tests/test_e2e.py -v --timeout=300 - name: Cleanup Vaultwarden if: always() diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index d486067..24586b6 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -8,11 +8,14 @@ on: jobs: opencode: - if: | - contains(github.event.comment.body, ' /oc') || - startsWith(github.event.comment.body, '/oc') || - contains(github.event.comment.body, ' /opencode') || - startsWith(github.event.comment.body, '/opencode') + if: > + (startsWith(github.event.comment.body, '/oc') || + startsWith(github.event.comment.body, '/opencode') || + contains(github.event.comment.body, ' /oc ') || + contains(github.event.comment.body, ' /opencode ')) && + (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 @@ -26,7 +29,7 @@ jobs: persist-credentials: false - name: Run opencode - uses: anomalyco/opencode/github@latest + uses: anomalyco/opencode/github@v0 env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..79a1f41 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule ".skills/greptile"] + path = .skills/greptile + url = https://github.com/greptileai/skills.git diff --git a/.skills/greptile b/.skills/greptile new file mode 160000 index 0000000..4ae5198 --- /dev/null +++ b/.skills/greptile @@ -0,0 +1 @@ +Subproject commit 4ae5198fb82fe28d7b452796152f2b1745051c77 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/run.py b/src/run.py index f5140a5..21fceb5 100644 --- a/src/run.py +++ b/src/run.py @@ -42,12 +42,12 @@ def main(): # 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.decode("utf-8") if org_export_mode_raw else "single" - org_export_mode = raw_value if raw_value in ("single", "multiple") else "single" + raw_value = org_export_mode_raw if org_export_mode_raw else "multiple" + org_export_mode = raw_value if raw_value in ("single", "multiple") else "multiple" if raw_value != org_export_mode: - logger.warning(f"Invalid org_export_mode '{raw_value}', defaulting to 'single'") + logger.warning(f"Invalid org_export_mode '{raw_value}', defaulting to 'multiple'") configured_org_ids = ( - [org.strip() for org in org_ids_raw.decode().split(",") if org.strip()] + [org.strip() for org in org_ids_raw.split(",") if org.strip()] if org_ids_raw else [] ) @@ -146,15 +146,26 @@ def main(): if encryption_mode == "raw": all_org_data = {} for org_id in org_ids: - org_data = source.export_organization_raw(org_id) - all_org_data[org_id] = org_data - - 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}.") + 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 encryption_mode == "bitwarden": logger.error( diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 40eb6ff..5b6379e 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -233,31 +233,20 @@ def test_list_items(self, bw_session): items = json.loads(result.stdout) assert isinstance(items, list) - def test_backup_personal_vault_raw_mode(self, bw_session, tmp_path): + 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 - old_test_mode = os.environ.get("TEST_MODE") - old_appdata = os.environ.get("BITWARDENCLI_APPDATA_DIR") - os.environ["TEST_MODE"] = "1" - os.environ["BITWARDENCLI_APPDATA_DIR"] = bw_session["bw_env"][ - "BITWARDENCLI_APPDATA_DIR" - ] + 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" - try: - client.export_raw_encrypted(str(backup_file), "backup_password") - finally: - if old_test_mode is not None: - os.environ["TEST_MODE"] = old_test_mode - else: - os.environ.pop("TEST_MODE", None) - if old_appdata is not None: - os.environ["BITWARDENCLI_APPDATA_DIR"] = old_appdata - else: - os.environ.pop("BITWARDENCLI_APPDATA_DIR", None) + client.export_raw_encrypted(str(backup_file), "backup_password") assert backup_file.exists() assert backup_file.stat().st_size > 0 @@ -311,7 +300,6 @@ def test_docker_image_has_required_binaries(self): result = subprocess.run( ["docker", "run", "--rm", "backvault:latest", "which", binary], capture_output=True, - check=True, ) assert result.returncode == 0, f"Binary {binary} not found in image" diff --git a/tests/test_run.py b/tests/test_run.py index 77e0f46..e7e39f6 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -31,8 +31,8 @@ def test_main_bitwarden_encryption( "test_client_secret", "test_master_pw", "test_file_pw", - b"", # organization_ids (empty) - b"single", # org_export_mode + "", # organization_ids (empty) + "multiple", # org_export_mode ] mock_client_instance = mock_bw_client.return_value mock_sprun.return_value = CompletedProcess( @@ -81,8 +81,8 @@ 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", - b"", # organization_ids (empty) - b"single", # org_export_mode + "", # organization_ids (empty) + "multiple", # org_export_mode ] mock_client_instance = mock_bw_client.return_value mock_sprun.return_value = CompletedProcess( @@ -130,8 +130,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", - b"", # organization_ids (empty) - b"single", # org_export_mode + "", # organization_ids (empty) + "multiple", # org_export_mode ] mock_client_instance = mock_bw_client.return_value @@ -164,8 +164,8 @@ def test_main_login_fails(mock_bw_client, mock_get_key, mock_db_connect): "test_client_secret", "test_master_pw", "test_file_pw", - b"", # organization_ids (empty) - b"single", # org_export_mode + "", # organization_ids (empty) + "multiple", # org_export_mode ] mock_client_instance = mock_bw_client.return_value mock_client_instance.login.side_effect = Exception("Login failed") @@ -199,8 +199,8 @@ def test_main_unlock_fails(mock_bw_client, mock_get_key, mock_db_connect): "test_client_secret", "test_master_pw", "test_file_pw", - b"", # organization_ids (empty) - b"single", # org_export_mode + "", # organization_ids (empty) + "multiple", # org_export_mode ] mock_client_instance = mock_bw_client.return_value mock_client_instance.unlock.side_effect = Exception("Unlock failed") From d1231c92c2b51fe37349f2defa21964283f0be29 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Tue, 21 Apr 2026 14:12:43 +0200 Subject: [PATCH 20/38] Fix init.py default to multiple; fix personal file condition --- .skills/greptile | 1 - src/init.py | 2 +- src/run.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) delete mode 160000 .skills/greptile diff --git a/.skills/greptile b/.skills/greptile deleted file mode 160000 index 4ae5198..0000000 --- a/.skills/greptile +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4ae5198fb82fe28d7b452796152f2b1745051c77 diff --git a/src/init.py b/src/init.py index de5dff0..93a5e7a 100644 --- a/src/init.py +++ b/src/init.py @@ -47,7 +47,7 @@ def init( client_secret: str = Form(...), file_password: str = Form(...), organization_ids: str = Form(""), - org_export_mode: str = Form("single"), + org_export_mode: str = Form("multiple"), ): conn, cursor = db_connect(DB_PATH, PRAGMA_KEY_FILE) if not conn or not cursor: diff --git a/src/run.py b/src/run.py index 21fceb5..b6419be 100644 --- a/src/run.py +++ b/src/run.py @@ -122,7 +122,7 @@ def main(): has_orgs = len(org_ids) > 0 # Export personal vault - if org_export_mode == "multiple" or has_orgs: + 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") From a4b9ffc355eea7cb22725a6438f4e46baa2fbeff Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Tue, 21 Apr 2026 14:22:22 +0200 Subject: [PATCH 21/38] Add error handling for multiple-mode org exports --- src/run.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/run.py b/src/run.py index b6419be..0c5282c 100644 --- a/src/run.py +++ b/src/run.py @@ -181,11 +181,16 @@ def main(): org_file = os.path.join( backup_dir, f"backup_{timestamp}_org-{org_id}.enc" ) - 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}") + 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.") From bdb861b956326bffe5fcbb8cccd703c5f0d8281f Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Tue, 21 Apr 2026 14:49:26 +0200 Subject: [PATCH 22/38] Fix single+bitwarden silent exit to warning --- src/run.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/run.py b/src/run.py index 0c5282c..283c517 100644 --- a/src/run.py +++ b/src/run.py @@ -168,13 +168,11 @@ def main(): logger.info(f"Organization export completed to {org_file}.") elif encryption_mode == "bitwarden": - logger.error( + logger.warning( f"org_export_mode='single' is not supported with encryption_mode='bitwarden'. " - f"Use org_export_mode='multiple' or switch to encryption_mode='raw'. " - f"The 'single' mode requires export_organization_bitwarden to produce " - f"backup_{timestamp}_orgs.enc which is not supported by Bitwarden CLI." + f"Skipping org export. Use org_export_mode='multiple' or switch to " + f"encryption_mode='raw' to export organizations." ) - return elif org_export_mode == "multiple" and has_orgs: for org_id in org_ids: From ed929752a807b8a4bcd79b213f56a91112a4b198 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Tue, 21 Apr 2026 15:34:48 +0200 Subject: [PATCH 23/38] Enhance CI/CD workflows, improve error handling, and update organization export logic - Added Docker Hub image name to CI environment variables. - Updated system dependencies in CI and E2E workflows. - Improved organization export mode validation and logging in run.py. - Enhanced HTML form for organization export mode with better accessibility. - Refactored entrypoint.sh for safer user and group modifications. - Removed obsolete .gitmodules file. - Updated tests to ensure proper error handling and organization listing. --- .github/workflows/ci.yml | 10 ++++--- .github/workflows/e2e.yml | 4 +-- .github/workflows/opencode.yml | 11 +++---- .gitmodules | 3 -- Dockerfile | 3 +- entrypoint.sh | 15 ++++++++-- src/form.html | 13 ++++++-- src/run.py | 32 +++++++++++++++----- tests/test_e2e.py | 55 ++++++++++++++++++++-------------- tests/test_run.py | 10 +++++++ 10 files changed, 106 insertions(+), 50 deletions(-) delete mode 100644 .gitmodules diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5d2899..cb21c1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ on: env: IMAGE_NAME: ${{ github.repository }} + DOCKER_HUB_IMAGE_NAME: mvflc/backvault jobs: lint: @@ -24,7 +25,7 @@ jobs: python-version: '3.13' - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y libsqlcipher-dev + run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev libsqlcipher-dev libssl-dev - name: Install uv run: pip install uv @@ -147,7 +148,7 @@ jobs: run: | docker run --rm --platform ${{ matrix.platform }} \ ${{ env.IMAGE_NAME }}:${{ steps.platform-slug.outputs.slug }}-test \ - test -d /app/backups && test -d /app/db && test -d /app/logs + sh -c 'test -d /app/backups && test -d /app/db && test -d /app/logs' docker-push: name: Docker Push @@ -155,6 +156,7 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: + contents: read packages: write steps: - uses: actions/checkout@v4 @@ -187,7 +189,7 @@ jobs: tags: | ghcr.io/${{ env.IMAGE_NAME }}:latest ghcr.io/${{ env.IMAGE_NAME }}:sha-${{ github.sha }} - ${{ env.IMAGE_NAME }}:latest - ${{ 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 index 545f013..9e89783 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -24,7 +24,7 @@ jobs: name: E2E Tests if: > (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') || - github.event_name == 'pull_request' || + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: @@ -83,7 +83,7 @@ jobs: node-version: '20' - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y libsqlcipher-dev + run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev libsqlcipher-dev libssl-dev - name: Install Bitwarden CLI run: | diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 24586b6..ddb5906 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -9,10 +9,8 @@ on: jobs: opencode: if: > - (startsWith(github.event.comment.body, '/oc') || - startsWith(github.event.comment.body, '/opencode') || - contains(github.event.comment.body, ' /oc ') || - contains(github.event.comment.body, ' /opencode ')) && + (matches(github.event.comment.body, '(^|\\s)/oc(\\s|$)') || + matches(github.event.comment.body, '(^|\\s)/opencode(\\s|$)')) && (github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR') @@ -28,8 +26,11 @@ jobs: with: persist-credentials: false + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev libsqlcipher-dev libssl-dev + - name: Run opencode - uses: anomalyco/opencode/github@v0 + uses: anomalyco/opencode/github@v1.14.19 env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 79a1f41..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule ".skills/greptile"] - path = .skills/greptile - url = https://github.com/greptileai/skills.git diff --git a/Dockerfile b/Dockerfile index b69d42d..0d83bd1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,8 @@ RUN apk update && apk add --no-cache \ RUN apk upgrade -a -RUN addgroup -S appgroup 2>/dev/null || true && adduser -S appuser -G appgroup 2>/dev/null || true +RUN if ! getent group appgroup > /dev/null 2>&1; then addgroup -S appgroup; fi && \ + if ! id -u appuser > /dev/null 2>&1; then adduser -S appuser -G appgroup; fi # Install Bitwarden CLI RUN set -eux; \ diff --git a/entrypoint.sh b/entrypoint.sh index 9d9f32f..9df9f4e 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -8,15 +8,24 @@ PGID=${PGID:-1000} if [ "$(id -g appuser)" != "$PGID" ]; then echo "Changing appuser group to PGID $PGID" delgroup appuser >/dev/null 2>&1 || true - addgroup -g "$PGID" appgroup 2>/dev/null || true - addgroup appuser appgroup 2>/dev/null || true + if ! addgroup -g "$PGID" appgroup 2>&1; then + echo "Error: Failed to create group appgroup with GID $PGID" >&2 + exit 1 + fi + if ! addgroup appuser appgroup 2>&1; then + echo "Error: Failed to add user appuser to group 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/src/form.html b/src/form.html index 62fa7ea..dc6d7a5 100644 --- a/src/form.html +++ b/src/form.html @@ -116,12 +116,19 @@

BackVault Setup

- - - + +

Leave empty to automatically export all accessible organizations.

diff --git a/src/run.py b/src/run.py index 283c517..c967be9 100644 --- a/src/run.py +++ b/src/run.py @@ -42,10 +42,13 @@ def main(): # 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 if org_export_mode_raw else "multiple" - org_export_mode = raw_value if raw_value in ("single", "multiple") else "multiple" + raw_value = org_export_mode_raw if org_export_mode_raw else "single" + org_export_mode = raw_value if raw_value in ("single", "multiple") else "single" if raw_value != org_export_mode: - logger.warning(f"Invalid org_export_mode '{raw_value}', defaulting to 'multiple'") + logger.warning( + f"Invalid org_export_mode '{raw_value}' (org_export_mode_raw={org_export_mode_raw!r}), " + f"defaulting to 'single'" + ) configured_org_ids = ( [org.strip() for org in org_ids_raw.split(",") if org.strip()] if org_ids_raw @@ -117,6 +120,19 @@ def main(): ) org_ids = [] + # Validate org IDs to prevent path traversal in filenames + safe_org_ids = [] + 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}'" + ) + safe_org_ids.append(safe_id) + org_ids = safe_org_ids + # Generate timestamped filename timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") has_orgs = len(org_ids) > 0 @@ -169,9 +185,9 @@ def main(): elif encryption_mode == "bitwarden": logger.warning( - f"org_export_mode='single' is not supported with encryption_mode='bitwarden'. " - f"Skipping org export. Use org_export_mode='multiple' or switch to " - f"encryption_mode='raw' to export organizations." + "org_export_mode='single' is not supported with encryption_mode='bitwarden'. " + "Skipping org export. Use org_export_mode='multiple' or switch to " + "encryption_mode='raw' to export organizations." ) elif org_export_mode == "multiple" and has_orgs: @@ -181,7 +197,9 @@ def main(): ) try: if encryption_mode == "raw": - source.export_organization_raw_encrypted(org_file, file_pw, org_id) + 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}") diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 5b6379e..cbc1849 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -135,12 +135,13 @@ def test_user(vaultwarden_container, bw_env): env=bw_env, ) - if result.returncode != 0 and "already exists" not in result.stderr.lower(): - if "can only be" in result.stderr.lower(): + if result.returncode != 0: + if "already exists" in result.stderr.lower(): pass else: print(f"Register output: {result.stdout}") print(f"Register error: {result.stderr}") + pytest.fail(f"Registration failed: {result.stderr}") yield {"email": TEST_EMAIL, "password": TEST_PASSWORD, "bw_env": bw_env} @@ -305,23 +306,29 @@ def test_docker_image_has_required_binaries(self): def test_entrypoint_exists(self): """Verify entrypoint script is executable.""" - try: - result = subprocess.run( - [ - "docker", - "run", - "--rm", - "backvault:latest", - "ls", - "-la", - "/app/entrypoint.sh", - ], - capture_output=True, - text=True, - check=True, - ) - except subprocess.CalledProcessError: - pytest.skip("Image not built yet") + result = subprocess.run( + [ + "docker", + "run", + "--rm", + "backvault:latest", + "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 @@ -340,15 +347,19 @@ def test_invalid_session_handling(self, bw_env): assert result.returncode != 0 - def test_unlock_with_wrong_password(self, bw_env): + def test_unlock_with_wrong_password(self, bw_session): """Test unlock with wrong password fails properly.""" - subprocess.run(["bw", "lock"], capture_output=True, env=bw_env) + 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={**bw_env, "BW_SESSION": ""}, + env={**locked_env, "BW_SESSION": session}, ) assert result.returncode != 0 diff --git a/tests/test_run.py b/tests/test_run.py index e7e39f6..8d404da 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -38,6 +38,9 @@ def test_main_bitwarden_encryption( 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() @@ -54,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") @@ -88,6 +92,9 @@ def test_main_raw_encryption(mock_bw_client, mock_sprun, mock_get_key, mock_db_c 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() @@ -104,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") @@ -169,6 +177,7 @@ def test_main_login_fails(mock_bw_client, mock_get_key, mock_db_connect): ] 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() @@ -204,6 +213,7 @@ def test_main_unlock_fails(mock_bw_client, mock_get_key, mock_db_connect): ] 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() From 4597a2d08bdfb6b784dac601dc9633e63f894e36 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Tue, 21 Apr 2026 17:49:04 +0200 Subject: [PATCH 24/38] fix appgroup --- Dockerfile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0d83bd1..9eb067d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,8 +28,10 @@ RUN apk update && apk add --no-cache \ RUN apk upgrade -a -RUN if ! getent group appgroup > /dev/null 2>&1; then addgroup -S appgroup; fi && \ - if ! id -u appuser > /dev/null 2>&1; then adduser -S appuser -G appgroup; fi +RUN getent group appgroup > /dev/null 2>&1 \ + || addgroup -S appgroup && \ + id -u appuser > /dev/null 2>&1 \ + || adduser -S appuser -G appgroup # Install Bitwarden CLI RUN set -eux; \ From b36156223fef4e0946fdcd09050f97e152db2803 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Wed, 22 Apr 2026 08:20:55 +0200 Subject: [PATCH 25/38] Update CI workflows, enhance error handling, and improve organization export logic --- .github/workflows/e2e.yml | 55 ++++++++++++++++++++-------------- .github/workflows/opencode.yml | 8 +++-- .gitignore | 1 + entrypoint.sh | 15 +++++----- src/form.html | 24 +++++++++++---- src/init.py | 4 +++ tests/test_e2e.py | 7 ++--- tests/test_run.py | 44 +++++++++++++-------------- 8 files changed, 93 insertions(+), 65 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9e89783..bd6ab94 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -17,7 +17,8 @@ on: env: IMAGE_NAME: ${{ github.repository }} - VAULTWARDEN_URL: http://localhost:8080 + VAULTWARDEN_PORT: '8089' + VAULTWARDEN_URL: http://localhost:8089 jobs: e2e: @@ -37,40 +38,48 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Build Docker image - uses: docker/build-push-action@v5 - with: - platforms: linux/amd64 - load: true - tags: | - ${{ env.IMAGE_NAME }}:e2e-test - backvault:latest - cache-from: type=gha - cache-to: type=gha,mode=max + run: | + docker build \ + --platform linux/amd64 \ + --tag "${{ env.IMAGE_NAME }}:e2e-test" \ + --tag backvault:latest \ + . - name: Start Vaultwarden run: | + docker rm -f backvault-e2e-vaultwarden 2>/dev/null || true docker run -d \ --name backvault-e2e-vaultwarden \ - -p 8080:80 \ + -p "${{ env.VAULTWARDEN_PORT }}:80" \ -e SIGNUPS_ALLOWED=true \ -e ADMIN_TOKEN=${{ secrets.ADMIN_TOKEN }} \ + -e I_REALLY_WANT_VOLATILE_STORAGE=true \ vaultwarden/server:latest - - - name: Wait for Vaultwarden to start - run: | - echo "Waiting for Vaultwarden..." + VW_IP=$(docker inspect backvault-e2e-vaultwarden --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}') for i in $(seq 1 30); do - if curl -sf http://localhost:8080/health > /dev/null 2>&1; then - echo "Vaultwarden is ready" - break + HTTP_CODE=$(curl -so /dev/null -w '%{http_code}' -L "http://${VW_IP}:80/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..." + echo "Attempt $i/30: Waiting... (HTTP $HTTP_CODE)" sleep 2 done + HTTP_CODE=$(curl -so /dev/null -w '%{http_code}' -L "http://${VW_IP}:80/api/config" 2>/dev/null) + echo "Error: Vaultwarden failed to start (HTTP $HTTP_CODE)" + docker logs backvault-e2e-vaultwarden + docker stop backvault-e2e-vaultwarden 2>/dev/null || true + docker rm backvault-e2e-vaultwarden 2>/dev/null || true + exit 1 - name: Verify Vaultwarden is running run: | - curl -sf http://localhost:8080/health || exit 1 + VW_IP=$(docker inspect backvault-e2e-vaultwarden --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}') + HTTP_CODE=$(curl -so /dev/null -w '%{http_code}' -L "http://${VW_IP}:80/api/config" 2>/dev/null) + if [ "$HTTP_CODE" != "200" ]; then + echo "Error: Vaultwarden health check failed (HTTP $HTTP_CODE)" + exit 1 + fi - name: Set up Python uses: actions/setup-python@v5 @@ -109,10 +118,10 @@ jobs: - name: Cleanup Vaultwarden if: always() run: | - docker stop backvault-e2e-vaultwarden || true - docker rm backvault-e2e-vaultwarden || true + docker stop backvault-e2e-vaultwarden 2>/dev/null || true + docker rm backvault-e2e-vaultwarden 2>/dev/null || true - name: Cleanup Docker buildx if: always() run: | - docker buildx prune --all || true \ No newline at end of file + 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 index ddb5906..03a61fd 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -9,8 +9,12 @@ on: jobs: opencode: if: > - (matches(github.event.comment.body, '(^|\\s)/oc(\\s|$)') || - matches(github.event.comment.body, '(^|\\s)/opencode(\\s|$)')) && + (github.event.comment.body == '/oc' || + github.event.comment.body == '/opencode' || + startsWith(github.event.comment.body, '/oc ') || + startsWith(github.event.comment.body, '/opencode ') || + contains(github.event.comment.body, '\n/oc') || + contains(github.event.comment.body, '\n/opencode')) && (github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR') diff --git a/.gitignore b/.gitignore index 06adb5c..e33ef79 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ db/ trivy *bandit* .pre-commit* +.secrets \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh index 9df9f4e..dba4ec7 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -8,14 +8,15 @@ PGID=${PGID:-1000} if [ "$(id -g appuser)" != "$PGID" ]; then echo "Changing appuser group to PGID $PGID" delgroup appuser >/dev/null 2>&1 || true - if ! addgroup -g "$PGID" appgroup 2>&1; then - echo "Error: Failed to create group appgroup with GID $PGID" >&2 - exit 1 - fi - if ! addgroup appuser appgroup 2>&1; then - echo "Error: Failed to add user appuser to group appgroup" >&2 - exit 1 + if getent group appgroup >/dev/null 2>&1; then + current_gid=$(getent group appgroup | cut -d: -f3) + if [ "$current_gid" != "$PGID" ]; then + echo "Warning: appgroup exists with GID $current_gid, using existing group" + fi + else + addgroup -g "$PGID" appgroup fi + addgroup appuser appgroup 2>/dev/null || true fi # Modify user if PUID provided diff --git a/src/form.html b/src/form.html index dc6d7a5..31ef4ff 100644 --- a/src/form.html +++ b/src/form.html @@ -78,6 +78,13 @@ } .org-warning { + font-size: 12px; + color: #888; + margin-top: -14px; + margin-bottom: 18px; + } + + .org-error { font-size: 12px; color: #ff6b6b; margin-top: -14px; @@ -116,17 +123,22 @@

BackVault Setup

- + - +

Single mode is not supported with Bitwarden encryption. Use "Separate files" or set BACKUP_ENCRYPTION_MODE=raw.

diff --git a/src/init.py b/src/init.py index 93a5e7a..8c8f420 100644 --- a/src/init.py +++ b/src/init.py @@ -53,6 +53,10 @@ def init( 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"): + org_export_mode = "single" + # Store encrypted passwords and keys put_key(conn, "master_password", master_password.encode()) put_key(conn, "client_id", client_id.encode()) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index cbc1849..c625dfb 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -169,10 +169,7 @@ def bw_session(test_user): ) if result.returncode != 0: - if "not found" in result.stderr.lower() or "invalid" in result.stderr.lower(): - pytest.skip(f"Cannot login to test Vaultwarden: {result.stderr}") - else: - pytest.fail(f"Failed to login: {result.stderr}") + pytest.fail(f"Login failed: {result.stderr}") session = result.stdout.strip() @@ -189,7 +186,7 @@ def bw_session(test_user): ) if result.returncode != 0: - pytest.skip(f"Cannot unlock vault: {result.stderr}") + pytest.fail(f"Unlock failed: {result.stderr}") session = result.stdout.strip() bw_env["BW_SESSION"] = session diff --git a/tests/test_run.py b/tests/test_run.py index 8d404da..f815f74 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -27,10 +27,10 @@ def test_main_bitwarden_encryption( """ mock_db_connect.return_value = (MagicMock(), MagicMock()) mock_get_key.side_effect = [ - "test_client_id", - "test_client_secret", - "test_master_pw", - "test_file_pw", + b"test_client_id", + b"test_client_secret", + b"test_master_pw", + b"test_file_pw", "", # organization_ids (empty) "multiple", # org_export_mode ] @@ -49,12 +49,12 @@ def test_main_bitwarden_encryption( mock_bw_client.assert_called_once_with( bw_cmd="bw", server="https://test.server", - client_id="test_client_id", - client_secret="test_client_secret", + client_id=b"test_client_id", + client_secret=b"test_client_secret", use_api_key=True, ) mock_client_instance.login.assert_called_once() - mock_client_instance.unlock.assert_called_once_with("test_master_pw") + mock_client_instance.unlock.assert_called_once_with(b"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() @@ -81,10 +81,10 @@ def test_main_raw_encryption(mock_bw_client, mock_sprun, mock_get_key, mock_db_c """ mock_db_connect.return_value = (MagicMock(), MagicMock()) mock_get_key.side_effect = [ - "test_client_id", - "test_client_secret", - "test_master_pw", - "test_file_pw", + b"test_client_id", + b"test_client_secret", + b"test_master_pw", + b"test_file_pw", "", # organization_ids (empty) "multiple", # org_export_mode ] @@ -103,12 +103,12 @@ def test_main_raw_encryption(mock_bw_client, mock_sprun, mock_get_key, mock_db_c mock_bw_client.assert_called_once_with( bw_cmd="bw", server="https://test.server", - client_id="test_client_id", - client_secret="test_client_secret", + client_id=b"test_client_id", + client_secret=b"test_client_secret", use_api_key=True, ) mock_client_instance.login.assert_called_once() - mock_client_instance.unlock.assert_called_once_with("test_master_pw") + mock_client_instance.unlock.assert_called_once_with(b"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() @@ -134,10 +134,10 @@ def test_main_invalid_encryption_mode(mock_bw_client, mock_get_key, mock_db_conn """ mock_db_connect.return_value = (MagicMock(), MagicMock()) mock_get_key.side_effect = [ - "test_client_id", - "test_client_secret", - "test_master_pw", - "test_file_pw", + b"test_client_id", + b"test_client_secret", + b"test_master_pw", + b"test_file_pw", "", # organization_ids (empty) "multiple", # org_export_mode ] @@ -168,10 +168,10 @@ def test_main_login_fails(mock_bw_client, mock_get_key, mock_db_connect): """ mock_db_connect.return_value = (MagicMock(), MagicMock()) mock_get_key.side_effect = [ - "test_client_id", - "test_client_secret", - "test_master_pw", - "test_file_pw", + b"test_client_id", + b"test_client_secret", + b"test_master_pw", + b"test_file_pw", "", # organization_ids (empty) "multiple", # org_export_mode ] From 624eefcc1e05e305753f271a9d75da3c28d30b54 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Wed, 22 Apr 2026 09:53:56 +0200 Subject: [PATCH 26/38] - Fixed 15 issues across various files, including test_run.py, e2e.yml, init.py, run.py, and others. - Ensured all unit tests pass and excluded e2e tests from default runs. --- .github/workflows/e2e.yml | 53 +++++++++++++++------------------- .github/workflows/opencode.yml | 21 ++++++++++---- .gitignore | 6 +++- entrypoint.sh | 13 +++++++-- pytest.ini | 3 ++ src/form.html | 3 +- src/init.py | 4 +-- src/run.py | 37 ++++++++++++++++-------- tests/test_e2e.py | 32 ++++++++++++-------- tests/test_run.py | 44 ++++++++++++++-------------- 10 files changed, 127 insertions(+), 89 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bd6ab94..0ebe446 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -17,8 +17,8 @@ on: env: IMAGE_NAME: ${{ github.repository }} - VAULTWARDEN_PORT: '8089' - VAULTWARDEN_URL: http://localhost:8089 + VAULTWARDEN_PORT: '8888' + VAULTWARDEN_URL: http://localhost:8888 jobs: e2e: @@ -28,6 +28,15 @@ jobs: (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 @@ -37,6 +46,13 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Validate ADMIN_TOKEN + run: | + if [ -z "$ADMIN_TOKEN" ]; then + echo "Error: ADMIN_TOKEN secret is required" + exit 1 + fi + - name: Build Docker image run: | docker build \ @@ -45,19 +61,10 @@ jobs: --tag backvault:latest \ . - - name: Start Vaultwarden + - name: Wait for Vaultwarden run: | - docker rm -f backvault-e2e-vaultwarden 2>/dev/null || true - docker run -d \ - --name backvault-e2e-vaultwarden \ - -p "${{ env.VAULTWARDEN_PORT }}:80" \ - -e SIGNUPS_ALLOWED=true \ - -e ADMIN_TOKEN=${{ secrets.ADMIN_TOKEN }} \ - -e I_REALLY_WANT_VOLATILE_STORAGE=true \ - vaultwarden/server:latest - VW_IP=$(docker inspect backvault-e2e-vaultwarden --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}') for i in $(seq 1 30); do - HTTP_CODE=$(curl -so /dev/null -w '%{http_code}' -L "http://${VW_IP}:80/api/config" 2>/dev/null) + 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 @@ -65,21 +72,17 @@ jobs: echo "Attempt $i/30: Waiting... (HTTP $HTTP_CODE)" sleep 2 done - HTTP_CODE=$(curl -so /dev/null -w '%{http_code}' -L "http://${VW_IP}:80/api/config" 2>/dev/null) - echo "Error: Vaultwarden failed to start (HTTP $HTTP_CODE)" - docker logs backvault-e2e-vaultwarden - docker stop backvault-e2e-vaultwarden 2>/dev/null || true - docker rm backvault-e2e-vaultwarden 2>/dev/null || true + echo "Error: Vaultwarden failed to start" exit 1 - name: Verify Vaultwarden is running run: | - VW_IP=$(docker inspect backvault-e2e-vaultwarden --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}') - HTTP_CODE=$(curl -so /dev/null -w '%{http_code}' -L "http://${VW_IP}:80/api/config" 2>/dev/null) + 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 @@ -98,10 +101,6 @@ jobs: run: | npm install -g @bitwarden/cli - - name: Configure Bitwarden CLI - run: | - bw config server ${{ env.VAULTWARDEN_URL }} - - name: Install dependencies run: | pip install uv @@ -115,12 +114,6 @@ jobs: BW_TEST_MASTER_PASSWORD: ${{ secrets.BW_TEST_MASTER_PASSWORD }} run: uv run pytest tests/test_e2e.py -v --timeout=300 - - name: Cleanup Vaultwarden - if: always() - run: | - docker stop backvault-e2e-vaultwarden 2>/dev/null || true - docker rm backvault-e2e-vaultwarden 2>/dev/null || true - - name: Cleanup Docker buildx if: always() run: | diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 03a61fd..3dc1422 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -9,12 +9,6 @@ on: jobs: opencode: if: > - (github.event.comment.body == '/oc' || - github.event.comment.body == '/opencode' || - startsWith(github.event.comment.body, '/oc ') || - startsWith(github.event.comment.body, '/opencode ') || - contains(github.event.comment.body, '\n/oc') || - contains(github.event.comment.body, '\n/opencode')) && (github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR') @@ -30,10 +24,25 @@ jobs: 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 = body === '/oc' || + body === '/opencode' || + body.startsWith('/oc ') || + body.startsWith('/opencode ') || + body.includes('\n/oc ') || + body.includes('\n/opencode '); + core.setOutput('is_command', isCommand); + - name: Install dependencies 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 }} diff --git a/.gitignore b/.gitignore index e33ef79..99a298a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,8 @@ db/ trivy *bandit* .pre-commit* -.secrets \ No newline at end of file +.secrets +.coverage +coverage.xml +FLOW.md +STATE.md diff --git a/entrypoint.sh b/entrypoint.sh index dba4ec7..c6ce4f4 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -11,12 +11,19 @@ if [ "$(id -g appuser)" != "$PGID" ]; then if getent group appgroup >/dev/null 2>&1; then current_gid=$(getent group appgroup | cut -d: -f3) if [ "$current_gid" != "$PGID" ]; then - echo "Warning: appgroup exists with GID $current_gid, using existing group" + echo "Error: appgroup exists with GID $current_gid, cannot change to $PGID" >&2 + exit 1 fi else - addgroup -g "$PGID" appgroup + 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 - addgroup appuser appgroup 2>/dev/null || true fi # Modify user if PUID provided 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/form.html b/src/form.html index 31ef4ff..727e002 100644 --- a/src/form.html +++ b/src/form.html @@ -125,6 +125,7 @@

BackVault Setup

Single mode is not supported with Bitwarden encryption. Use "Separate files" or set BACKUP_ENCRYPTION_MODE=raw.

@@ -135,7 +136,7 @@

BackVault Setup

var form=document.querySelector('form'); var showError=function(){error.hidden=select.value!=='single'}; form.addEventListener('submit',function(e){ - if(select.value==='single'){e.preventDefault();alert('Single mode requires BACKUP_ENCRYPTION_MODE=raw. Please use "Separate files per organization".');} + if(select.value==='single'){e.preventDefault();alert('Single mode requires BACKUP_ENCRYPTION_MODE=raw. Please use "Separate files per organization" or "No organization exports".');} }); select.addEventListener('change',showError); showError(); diff --git a/src/init.py b/src/init.py index 8c8f420..b636865 100644 --- a/src/init.py +++ b/src/init.py @@ -54,8 +54,8 @@ def init( return HTMLResponse("Database connection failed", status_code=500) # Validate org_export_mode against allowlist - if org_export_mode not in ("single", "multiple"): - org_export_mode = "single" + 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()) diff --git a/src/run.py b/src/run.py index c967be9..d9b15c0 100644 --- a/src/run.py +++ b/src/run.py @@ -42,13 +42,10 @@ def main(): # 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 if org_export_mode_raw else "single" - org_export_mode = raw_value if raw_value in ("single", "multiple") else "single" - if raw_value != org_export_mode: - logger.warning( - f"Invalid org_export_mode '{raw_value}' (org_export_mode_raw={org_export_mode_raw!r}), " - f"defaulting to 'single'" - ) + 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 @@ -121,7 +118,9 @@ def main(): org_ids = [] # Validate org IDs to prevent path traversal in filenames - safe_org_ids = [] + # Keep original org_ids for export calls, create separate map for safe filenames + safe_suffixes = {} + seen_suffixes = {} for org_id in org_ids: if org_id is None: continue @@ -130,8 +129,14 @@ def main(): logger.warning( f"Org ID '{org_id}' contains unsafe characters, replaced with '{safe_id}'" ) - safe_org_ids.append(safe_id) - org_ids = safe_org_ids + # Handle collisions by appending counter + if safe_id in seen_suffixes: + counter = seen_suffixes[safe_id] + 1 + seen_suffixes[safe_id] = counter + safe_id = f"{safe_id}_{counter}" + else: + seen_suffixes[safe_id] = 0 + safe_suffixes[org_id] = safe_id # Generate timestamped filename timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -158,7 +163,14 @@ def main(): logger.info(f"Personal vault export completed to {personal_file}.") # Export organizations - if org_export_mode == "single" and has_orgs: + # None means default to multiple (export all orgs) + if org_export_mode is None: + org_export_mode = "multiple" + logger.info("org_export_mode not configured, defaulting to 'multiple'") + + if org_export_mode == "none": + logger.info("Organization exports disabled by user configuration") + elif org_export_mode == "single" and has_orgs: if encryption_mode == "raw": all_org_data = {} for org_id in org_ids: @@ -192,8 +204,9 @@ def main(): 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-{org_id}.enc" + backup_dir, f"backup_{timestamp}_org-{safe_suffix}.enc" ) try: if encryption_mode == "raw": diff --git a/tests/test_e2e.py b/tests/test_e2e.py index c625dfb..b917407 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -5,22 +5,29 @@ - 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:8080 \ + 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 + 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 -VAULTWARDEN_URL = os.getenv("VAULTWARDEN_URL", "http://localhost:8080") +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!") @@ -50,14 +57,14 @@ def vaultwarden_container(): return result = subprocess.run( - ["docker", "ps", "--filter", "publish=8080", "--format", "{{.Names}}"], + ["docker", "ps", "--filter", f"publish={VAULTWARDEN_PORT}", "--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 8080: {container_name}") + print(f"Using existing container on port {VAULTWARDEN_PORT}: {container_name}") yield container_name return @@ -70,7 +77,7 @@ def vaultwarden_container(): "--name", container_name, "-p", - "8080:80", + f"{VAULTWARDEN_PORT}:80", "-e", "SIGNUPS_ALLOWED=true", "-e", @@ -286,17 +293,17 @@ class TestE2EDocker: def test_docker_image_has_required_binaries(self): """Verify Docker image has all required binaries.""" result = subprocess.run( - ["docker", "image", "inspect", "backvault:latest"], + ["docker", "image", "inspect", IMAGE_NAME], capture_output=True, ) if result.returncode != 0: - pytest.skip("Image not built yet: backvault:latest") + 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", "backvault:latest", "which", binary], + ["docker", "run", "--rm", IMAGE_NAME, "which", binary], capture_output=True, ) assert result.returncode == 0, f"Binary {binary} not found in image" @@ -308,7 +315,7 @@ def test_entrypoint_exists(self): "docker", "run", "--rm", - "backvault:latest", + IMAGE_NAME, "ls", "-la", "/app/entrypoint.sh", @@ -333,13 +340,14 @@ def test_entrypoint_exists(self): class TestE2EErrorHandling: """Test error handling with real Vaultwarden.""" - def test_invalid_session_handling(self, bw_env): + 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={**bw_env, "BW_SESSION": "invalid_session_key"}, + env=invalid_env, ) assert result.returncode != 0 diff --git a/tests/test_run.py b/tests/test_run.py index f815f74..8d404da 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -27,10 +27,10 @@ def test_main_bitwarden_encryption( """ mock_db_connect.return_value = (MagicMock(), MagicMock()) mock_get_key.side_effect = [ - b"test_client_id", - b"test_client_secret", - b"test_master_pw", - b"test_file_pw", + "test_client_id", + "test_client_secret", + "test_master_pw", + "test_file_pw", "", # organization_ids (empty) "multiple", # org_export_mode ] @@ -49,12 +49,12 @@ def test_main_bitwarden_encryption( mock_bw_client.assert_called_once_with( bw_cmd="bw", server="https://test.server", - client_id=b"test_client_id", - client_secret=b"test_client_secret", + client_id="test_client_id", + client_secret="test_client_secret", use_api_key=True, ) mock_client_instance.login.assert_called_once() - mock_client_instance.unlock.assert_called_once_with(b"test_master_pw") + 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() @@ -81,10 +81,10 @@ def test_main_raw_encryption(mock_bw_client, mock_sprun, mock_get_key, mock_db_c """ mock_db_connect.return_value = (MagicMock(), MagicMock()) mock_get_key.side_effect = [ - b"test_client_id", - b"test_client_secret", - b"test_master_pw", - b"test_file_pw", + "test_client_id", + "test_client_secret", + "test_master_pw", + "test_file_pw", "", # organization_ids (empty) "multiple", # org_export_mode ] @@ -103,12 +103,12 @@ def test_main_raw_encryption(mock_bw_client, mock_sprun, mock_get_key, mock_db_c mock_bw_client.assert_called_once_with( bw_cmd="bw", server="https://test.server", - client_id=b"test_client_id", - client_secret=b"test_client_secret", + client_id="test_client_id", + client_secret="test_client_secret", use_api_key=True, ) mock_client_instance.login.assert_called_once() - mock_client_instance.unlock.assert_called_once_with(b"test_master_pw") + 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() @@ -134,10 +134,10 @@ def test_main_invalid_encryption_mode(mock_bw_client, mock_get_key, mock_db_conn """ mock_db_connect.return_value = (MagicMock(), MagicMock()) mock_get_key.side_effect = [ - b"test_client_id", - b"test_client_secret", - b"test_master_pw", - b"test_file_pw", + "test_client_id", + "test_client_secret", + "test_master_pw", + "test_file_pw", "", # organization_ids (empty) "multiple", # org_export_mode ] @@ -168,10 +168,10 @@ def test_main_login_fails(mock_bw_client, mock_get_key, mock_db_connect): """ mock_db_connect.return_value = (MagicMock(), MagicMock()) mock_get_key.side_effect = [ - b"test_client_id", - b"test_client_secret", - b"test_master_pw", - b"test_file_pw", + "test_client_id", + "test_client_secret", + "test_master_pw", + "test_file_pw", "", # organization_ids (empty) "multiple", # org_export_mode ] From 6641dd757f00d5584f049b517a72f11937f48ad6 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Wed, 22 Apr 2026 10:45:55 +0200 Subject: [PATCH 27/38] Enhance E2E tests with ADMIN_TOKEN validation, improve opencode job condition, and update form error handling for organization export options --- .github/workflows/e2e.yml | 4 ++- .github/workflows/opencode.yml | 1 + src/form.html | 11 ++----- src/run.py | 21 +++++++----- tests/test_e2e.py | 60 +++++++++++++++++++--------------- 5 files changed, 52 insertions(+), 45 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0ebe446..a2485dd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -47,6 +47,8 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Validate ADMIN_TOKEN + env: + ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }} run: | if [ -z "$ADMIN_TOKEN" ]; then echo "Error: ADMIN_TOKEN secret is required" @@ -112,7 +114,7 @@ jobs: BW_TEST_EMAIL: ${{ secrets.BW_TEST_EMAIL }} BW_TEST_PASSWORD: ${{ secrets.BW_TEST_PASSWORD }} BW_TEST_MASTER_PASSWORD: ${{ secrets.BW_TEST_MASTER_PASSWORD }} - run: uv run pytest tests/test_e2e.py -v --timeout=300 + run: uv run pytest tests/test_e2e.py -v -m e2e -o "addopts=" --timeout=300 - name: Cleanup Docker buildx if: always() diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 3dc1422..2464af3 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -39,6 +39,7 @@ jobs: 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 diff --git a/src/form.html b/src/form.html index 727e002..2e23ec8 100644 --- a/src/form.html +++ b/src/form.html @@ -126,20 +126,15 @@

BackVault Setup

Single mode is not supported with Bitwarden encryption. Use "Separate files" or set BACKUP_ENCRYPTION_MODE=raw.

diff --git a/src/run.py b/src/run.py index d9b15c0..ea24f8e 100644 --- a/src/run.py +++ b/src/run.py @@ -120,7 +120,7 @@ def main(): # 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 = {} + seen_suffixes = set() for org_id in org_ids: if org_id is None: continue @@ -129,14 +129,17 @@ def main(): logger.warning( f"Org ID '{org_id}' contains unsafe characters, replaced with '{safe_id}'" ) - # Handle collisions by appending counter - if safe_id in seen_suffixes: - counter = seen_suffixes[safe_id] + 1 - seen_suffixes[safe_id] = counter - safe_id = f"{safe_id}_{counter}" - else: - seen_suffixes[safe_id] = 0 - safe_suffixes[org_id] = 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") diff --git a/tests/test_e2e.py b/tests/test_e2e.py index b917407..25d4bae 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -57,7 +57,16 @@ def vaultwarden_container(): return result = subprocess.run( - ["docker", "ps", "--filter", f"publish={VAULTWARDEN_PORT}", "--format", "{{.Names}}"], + [ + "docker", + "ps", + "--filter", + f"publish={VAULTWARDEN_PORT}", + "--filter", + "ancestor=vaultwarden/server", + "--format", + "{{.Names}}", + ], capture_output=True, text=True, ) @@ -104,8 +113,6 @@ def vaultwarden_container(): else: pytest.fail("Vaultwarden failed to start") - time.sleep(5) - yield container_name print(f"Stopping Vaultwarden container: {container_name}") @@ -122,33 +129,32 @@ def bw_env(tmp_path_factory): @pytest.fixture(scope="function") def test_user(vaultwarden_container, bw_env): - """Create test user in Vaultwarden.""" - 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", - "register", - TEST_EMAIL, - "--password", - TEST_PASSWORD, - "--master-password", - TEST_MASTER_PASSWORD, - ], - capture_output=True, - text=True, - env=bw_env, + """Create test user in Vaultwarden via admin API.""" + import urllib.request + import urllib.error + + admin_url = VAULTWARDEN_URL.replace("http://", "http://admin:") + req = urllib.request.Request( + f"{admin_url}/admin/users", + 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", ) - - if result.returncode != 0: - if "already exists" in result.stderr.lower(): + 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: - print(f"Register output: {result.stdout}") - print(f"Register error: {result.stderr}") - pytest.fail(f"Registration failed: {result.stderr}") + 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} From 9a4537e64cc2b05f6b113750abfb83ada95cbaf1 Mon Sep 17 00:00:00 2001 From: Matheus Date: Wed, 22 Apr 2026 11:21:44 +0200 Subject: [PATCH 28/38] Update src/form.html Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/form.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/form.html b/src/form.html index 2e23ec8..5b43437 100644 --- a/src/form.html +++ b/src/form.html @@ -128,15 +128,16 @@

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.

From c44f16e16ed0714451bb50d7a4a1bd06dd225e40 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Wed, 22 Apr 2026 11:26:33 +0200 Subject: [PATCH 29/38] ruff format --- tests/test_e2e.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 25d4bae..56f7e93 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -143,7 +143,9 @@ def test_user(vaultwarden_container, bw_env): "masterPassword": TEST_MASTER_PASSWORD, } ).encode(), - headers={"Authorization": f"Bearer {os.getenv('VAULTWARDEN_ADMIN_TOKEN', 'admin_secret_token_for_testing')}"}, + headers={ + "Authorization": f"Bearer {os.getenv('VAULTWARDEN_ADMIN_TOKEN', 'admin_secret_token_for_testing')}" + }, method="POST", ) try: From 88407e1583dbf832b0ac95f251643e469b52875f Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Wed, 22 Apr 2026 11:40:50 +0200 Subject: [PATCH 30/38] Add cleanup step for appgroup and appuser in E2E workflow --- .github/workflows/e2e.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a2485dd..30676a7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -46,6 +46,11 @@ jobs: - 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 }} From dcefb9c652a7fe950670de32b022942a65b0904a Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Wed, 22 Apr 2026 11:43:50 +0200 Subject: [PATCH 31/38] Refactor Dockerfile to create appgroup and appuser idempotently --- Dockerfile | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9eb067d..04c0789 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,10 +28,11 @@ RUN apk update && apk add --no-cache \ RUN apk upgrade -a -RUN getent group appgroup > /dev/null 2>&1 \ - || addgroup -S appgroup && \ - id -u appuser > /dev/null 2>&1 \ - || adduser -S appuser -G appgroup +# Create appgroup and appuser idempotently +RUN delgroup appgroup 2>/dev/null || true; \ + deluser appuser 2>/dev/null || true; \ + addgroup -S appgroup && \ + adduser -S appuser -G appgroup # Install Bitwarden CLI RUN set -eux; \ From e2cd14f6b9f024286f740722c124b89a9e38f69f Mon Sep 17 00:00:00 2001 From: Matheus Date: Wed, 22 Apr 2026 11:50:47 +0200 Subject: [PATCH 32/38] Update src/bw_client.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/bw_client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bw_client.py b/src/bw_client.py index 6bc0f5c..f883012 100644 --- a/src/bw_client.py +++ b/src/bw_client.py @@ -352,8 +352,7 @@ def export_organization_bitwarden( "--output", backup_file, "--format", - "json", - "--password", + "encrypted_json", file_pw, ], capture_json=False, From 15d6bac1b6eb14e1e5950016c103afc91a26b83d Mon Sep 17 00:00:00 2001 From: Matheus Date: Wed, 22 Apr 2026 12:11:49 +0200 Subject: [PATCH 33/38] Update src/bw_client.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/bw_client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/bw_client.py b/src/bw_client.py index f883012..d30f538 100644 --- a/src/bw_client.py +++ b/src/bw_client.py @@ -353,10 +353,12 @@ def export_organization_bitwarden( 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 From b4a0d1ce829e7c1b4415f6c6e6e6b65f56f695f2 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Wed, 22 Apr 2026 12:12:59 +0200 Subject: [PATCH 34/38] Update E2E workflow to pull Docker image and adjust organization export logic for backward compatibility --- .github/workflows/e2e.yml | 14 ++++++-------- src/run.py | 9 +++++---- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 30676a7..4977918 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -16,7 +16,7 @@ on: workflow_dispatch: env: - IMAGE_NAME: ${{ github.repository }} + IMAGE_NAME: docker.io/mvflc/backvault VAULTWARDEN_PORT: '8888' VAULTWARDEN_URL: http://localhost:8888 @@ -60,13 +60,10 @@ jobs: exit 1 fi - - name: Build Docker image + - name: Pull test image run: | - docker build \ - --platform linux/amd64 \ - --tag "${{ env.IMAGE_NAME }}:e2e-test" \ - --tag backvault:latest \ - . + docker pull docker.io/mvflc/backvault:test + docker tag docker.io/mvflc/backvault:test ${{ env.IMAGE_NAME }}:test - name: Wait for Vaultwarden run: | @@ -119,7 +116,8 @@ jobs: BW_TEST_EMAIL: ${{ secrets.BW_TEST_EMAIL }} BW_TEST_PASSWORD: ${{ secrets.BW_TEST_PASSWORD }} BW_TEST_MASTER_PASSWORD: ${{ secrets.BW_TEST_MASTER_PASSWORD }} - run: uv run pytest tests/test_e2e.py -v -m e2e -o "addopts=" --timeout=300 + IMAGE_NAME: ${{ env.IMAGE_NAME }} + run: uv run pytest tests/test_e2e.py -v -m e2e -o "addopts=" - name: Cleanup Docker buildx if: always() diff --git a/src/run.py b/src/run.py index ea24f8e..7aa9fb1 100644 --- a/src/run.py +++ b/src/run.py @@ -143,9 +143,10 @@ def main(): # Generate timestamped filename timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - has_orgs = len(org_ids) > 0 + has_orgs = len(org_ids) > 0 and org_export_mode != "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: @@ -166,10 +167,10 @@ def main(): logger.info(f"Personal vault export completed to {personal_file}.") # Export organizations - # None means default to multiple (export all orgs) + # None means default to "none" for safe upgrade (existing users don't get unexpected exports) if org_export_mode is None: - org_export_mode = "multiple" - logger.info("org_export_mode not configured, defaulting to 'multiple'") + 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") From 2ca9301914260c728e76d3b302e9d1f4ca6565c5 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Wed, 22 Apr 2026 12:15:13 +0200 Subject: [PATCH 35/38] Refactor logging statements for improved readability in organization export logic --- src/bw_client.py | 1 - src/run.py | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/bw_client.py b/src/bw_client.py index d30f538..7626533 100644 --- a/src/bw_client.py +++ b/src/bw_client.py @@ -358,7 +358,6 @@ def export_organization_bitwarden( ], capture_json=False, ) - ) def export_organization_raw_encrypted( self, backup_file: str, file_pw: str, org_id: str diff --git a/src/run.py b/src/run.py index 7aa9fb1..94029a4 100644 --- a/src/run.py +++ b/src/run.py @@ -170,7 +170,9 @@ def main(): # 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") + 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") From d062647c117d7747423c1f86d27d7aa0198d076f Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Wed, 22 Apr 2026 13:16:31 +0200 Subject: [PATCH 36/38] Fix organization export condition to handle None value for org_export_mode --- src/run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/run.py b/src/run.py index 94029a4..c30426a 100644 --- a/src/run.py +++ b/src/run.py @@ -143,7 +143,7 @@ def main(): # Generate timestamped filename timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - has_orgs = len(org_ids) > 0 and org_export_mode != "none" + 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 From 6c60dff00fb371f364011375daccb6a1045d4066 Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Wed, 22 Apr 2026 14:04:14 +0200 Subject: [PATCH 37/38] Enhance organization export logic and update E2E tests - Skip API call when organization exports are disabled - Improve command detection in opencode workflow - Update Dockerfile to ensure idempotent user and group creation - Fix HTML script tag closure in form.html - Adjust E2E test to use correct admin URL --- .github/workflows/e2e.yml | 2 +- .github/workflows/opencode.yml | 7 +------ Dockerfile | 10 ++++++---- src/form.html | 1 - src/run.py | 22 ++++++++++++++-------- tests/test_e2e.py | 4 ++-- 6 files changed, 24 insertions(+), 22 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4977918..572d061 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -116,7 +116,7 @@ jobs: 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 }} + IMAGE_NAME: ${{ env.IMAGE_NAME }}:test run: uv run pytest tests/test_e2e.py -v -m e2e -o "addopts=" - name: Cleanup Docker buildx diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 2464af3..0abcbb6 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -30,12 +30,7 @@ jobs: with: script: | const body = context.payload.comment.body; - const isCommand = body === '/oc' || - body === '/opencode' || - body.startsWith('/oc ') || - body.startsWith('/opencode ') || - body.includes('\n/oc ') || - body.includes('\n/opencode '); + const isCommand = /^\/(?:oc|opencode)(?:\s|$)/m.test(body); core.setOutput('is_command', isCommand); - name: Install dependencies diff --git a/Dockerfile b/Dockerfile index 04c0789..047f6df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,10 +29,12 @@ RUN apk update && apk add --no-cache \ RUN apk upgrade -a # Create appgroup and appuser idempotently -RUN delgroup appgroup 2>/dev/null || true; \ - deluser appuser 2>/dev/null || true; \ - addgroup -S appgroup && \ - adduser -S appuser -G appgroup +RUN if ! getent group appgroup > /dev/null 2>&1; then \ + addgroup -S appgroup; \ + fi && \ + if ! getent passwd appuser > /dev/null 2>&1; then \ + adduser -S appuser -G appgroup; \ + fi # Install Bitwarden CLI RUN set -eux; \ diff --git a/src/form.html b/src/form.html index 5b43437..1447b33 100644 --- a/src/form.html +++ b/src/form.html @@ -137,7 +137,6 @@

BackVault Setup

error.hidden = select.value !== 'single'; })(); -

Leave empty to automatically export all accessible organizations.

diff --git a/src/run.py b/src/run.py index c30426a..f569946 100644 --- a/src/run.py +++ b/src/run.py @@ -103,7 +103,11 @@ def main(): return # Determine org IDs to export (use configured or fetch all) - if configured_org_ids: + # 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: @@ -177,6 +181,15 @@ def main(): 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: @@ -201,13 +214,6 @@ def main(): f.write(encrypted_data) logger.info(f"Organization export completed to {org_file}.") - elif encryption_mode == "bitwarden": - logger.warning( - "org_export_mode='single' is not supported with encryption_mode='bitwarden'. " - "Skipping org export. Use org_export_mode='multiple' or switch to " - "encryption_mode='raw' to export organizations." - ) - elif org_export_mode == "multiple" and has_orgs: for org_id in org_ids: safe_suffix = safe_suffixes.get(org_id, org_id) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 56f7e93..128dc89 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -133,9 +133,9 @@ def test_user(vaultwarden_container, bw_env): import urllib.request import urllib.error - admin_url = VAULTWARDEN_URL.replace("http://", "http://admin:") + admin_url = f"{VAULTWARDEN_URL}/admin/users" req = urllib.request.Request( - f"{admin_url}/admin/users", + admin_url, data=urllib.parse.urlencode( { "email": TEST_EMAIL, From 38689e8a546277a27166bd66c54b7c5e4879263f Mon Sep 17 00:00:00 2001 From: Matheus Cunha Date: Wed, 22 Apr 2026 14:27:05 +0200 Subject: [PATCH 38/38] fix dockerfile appgroup --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 047f6df..135f6ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,12 +28,12 @@ RUN apk update && apk add --no-cache \ RUN apk upgrade -a -# Create appgroup and appuser idempotently +# 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 appgroup; \ + addgroup -S -g 1000 appgroup; \ fi && \ if ! getent passwd appuser > /dev/null 2>&1; then \ - adduser -S appuser -G appgroup; \ + adduser -S -u 1000 -G appgroup appuser; \ fi # Install Bitwarden CLI