diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fca86d4..eb2960d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build Multi-Arch Docker Image +name: Test and Release Pipeline on: workflow_dispatch: @@ -11,11 +11,45 @@ on: tags: - "v*" + pull_request: + branches: + - main + env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'npm' + + - name: Install bats + run: npm install -g bats + + - name: Fetch latest BWDC version + id: bwdc_version + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION=$(gh release view --repo bitwarden/directory-connector \ + --json tagName --jq '.tagName | ltrimstr("v")') + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + - name: Build image for integration tests + run: docker build --build-arg BWDC_VERSION=${{ steps.bwdc_version.outputs.version }} -t bwdc-test . + + - name: Run tests + run: ./tests/run_tests.sh + build: strategy: matrix: @@ -37,14 +71,21 @@ jobs: - name: Set version id: version + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | if [ -n "${{ inputs.BWDC_VERSION }}" ]; then echo "version=${{ inputs.BWDC_VERSION }}" >> $GITHUB_OUTPUT + elif [ "${{ github.event_name }}" = "pull_request" ]; then + VERSION=$(gh release view --repo bitwarden/directory-connector \ + --json tagName --jq '.tagName | ltrimstr("v")') + echo "version=${VERSION}" >> $GITHUB_OUTPUT else echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT fi - name: Log in to registry + if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} @@ -68,15 +109,17 @@ jobs: platforms: ${{ matrix.platform }} build-args: | BWDC_VERSION=${{ steps.version.outputs.version }} - outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} - name: Export digest + if: github.event_name != 'pull_request' run: | mkdir -p /tmp/digests digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - name: Upload digest + if: github.event_name != 'pull_request' uses: actions/upload-artifact@v4 with: name: digests-${{ steps.platform.outputs.name }} @@ -86,7 +129,10 @@ jobs: merge: runs-on: ubuntu-latest - needs: build + needs: + - build + - test + if: github.event_name != 'pull_request' permissions: contents: read @@ -126,4 +172,4 @@ jobs: - name: Inspect image run: | - docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} + docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 35a0224..11bfe1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ data.json .env +node_modules/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 619b343..f31c201 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM node:24-bookworm AS builder +FROM node:20-bookworm AS builder ARG BWDC_VERSION=2026.4.0 diff --git a/healthcheck.sh b/healthcheck.sh old mode 100644 new mode 100755 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8a0660f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "bitwarden-dc-docker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bitwarden-dc-docker", + "version": "1.0.0", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f427791 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "bitwarden-dc-docker", + "version": "1.0.0", + "description": "Running the Bitwarden Directory Connector on Docker", + "homepage": "https://github.com/acm-uiuc/bitwarden-dc-docker#readme", + "bugs": { + "url": "https://github.com/acm-uiuc/bitwarden-dc-docker/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/acm-uiuc/bitwarden-dc-docker.git" + }, + "license": "ISC", + "author": "", + "type": "commonjs", + "main": "index.js", + "directories": { + "test": "tests" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/tests/fixtures/mock_bwdc.sh b/tests/fixtures/mock_bwdc.sh new file mode 100755 index 0000000..e6ea7e5 --- /dev/null +++ b/tests/fixtures/mock_bwdc.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Records every invocation for assertion in tests +echo "$*" >> /tmp/bwdc_calls +case "$1" in + --version) echo "bwdc-mock 0.0.0"; exit 0 ;; + login) exit 0 ;; + sync) exit 0 ;; + config) exit 0 ;; + *) exit 0 ;; +esac diff --git a/tests/integration/container.bats b/tests/integration/container.bats new file mode 100644 index 0000000..7a4ce4e --- /dev/null +++ b/tests/integration/container.bats @@ -0,0 +1,220 @@ +#!/usr/bin/env bats + +# Set IMAGE_NAME in env to skip the local build (e.g. when testing a pre-built image). +IMAGE="${IMAGE_NAME:-bwdc-test}" +FIXTURES="$BATS_TEST_DIRNAME/../fixtures" +CONFIG_PATH="/home/bitwarden/.config/Bitwarden Directory Connector/data.json" + +setup_file() { + chmod +x "$FIXTURES/mock_bwdc.sh" + if [ -z "$IMAGE_NAME" ]; then + docker build -t "$IMAGE" "$BATS_TEST_DIRNAME/../.." >&2 + fi +} + +# --------------------------------------------------------------------------- +# Image / binary sanity checks (no credentials needed) +# --------------------------------------------------------------------------- + +@test "bwdc binary is present and executable" { + run docker run --rm --entrypoint /bin/sh "$IMAGE" \ + -c "test -x /usr/local/bin/bwdc && echo ok" + [ "$status" -eq 0 ] + [ "$output" = "ok" ] +} + +@test "container runs as non-root bitwarden user" { + run docker run --rm --entrypoint /bin/sh "$IMAGE" -c "id -un" + [ "$status" -eq 0 ] + [ "$output" = "bitwarden" ] +} + +@test "BITWARDENCLI_CONNECTOR_PLAINTEXT_SECRETS is true" { + run docker run --rm --entrypoint /bin/sh "$IMAGE" \ + -c 'echo "$BITWARDENCLI_CONNECTOR_PLAINTEXT_SECRETS"' + [ "$status" -eq 0 ] + [ "$output" = "true" ] +} + +@test "healthcheck.sh is present and executable" { + run docker run --rm --entrypoint /bin/sh "$IMAGE" \ + -c "test -x /usr/local/bin/healthcheck.sh && echo ok" + [ "$status" -eq 0 ] + [ "$output" = "ok" ] +} + +# --------------------------------------------------------------------------- +# Entrypoint behaviour +# --------------------------------------------------------------------------- + +@test "entrypoint exits 1 with an error when data.json is not mounted" { + run docker run --rm "$IMAGE" + [ "$status" -eq 1 ] + [[ "$output" =~ "Configuration file not found" ]] +} + +@test "sync loop starts and writes heartbeat file" { + local cid + cid=$(docker run -d \ + -v "$FIXTURES/mock_bwdc.sh:/usr/local/bin/bwdc" \ + -v "$FIXTURES/data.json:$CONFIG_PATH" \ + -e SYNC_INTERVAL_MIN=60 \ + "$IMAGE") + + # Poll up to 15s for the heartbeat to appear + local i=0 + while [ "$i" -lt 15 ]; do + docker exec "$cid" test -f /tmp/bwdc-heartbeat 2>/dev/null && break + sleep 1 + i=$((i + 1)) + done + + run docker exec "$cid" test -f /tmp/bwdc-heartbeat + docker rm -f "$cid" >/dev/null 2>&1 + [ "$status" -eq 0 ] +} + +@test "healthcheck passes after successful sync" { + local cid + cid=$(docker run -d \ + -v "$FIXTURES/mock_bwdc.sh:/usr/local/bin/bwdc" \ + -v "$FIXTURES/data.json:$CONFIG_PATH" \ + -e SYNC_INTERVAL_MIN=60 \ + "$IMAGE") + + # Wait for the sync loop to complete at least one iteration + local i=0 + while [ "$i" -lt 15 ]; do + docker exec "$cid" test -f /tmp/bwdc-heartbeat 2>/dev/null && break + sleep 1 + i=$((i + 1)) + done + + run docker exec "$cid" /usr/local/bin/healthcheck.sh + docker rm -f "$cid" >/dev/null 2>&1 + [ "$status" -eq 0 ] +} + +@test "BW_SERVER env var causes bwdc config server to be called" { + local cid + cid=$(docker run -d \ + -v "$FIXTURES/mock_bwdc.sh:/usr/local/bin/bwdc" \ + -v "$FIXTURES/data.json:$CONFIG_PATH" \ + -e BW_SERVER=https://bw.example.com \ + -e SYNC_INTERVAL_MIN=60 \ + "$IMAGE") + + sleep 8 + + run docker exec "$cid" cat /tmp/bwdc_calls + docker rm -f "$cid" >/dev/null 2>&1 + [ "$status" -eq 0 ] + [[ "$output" =~ "config server https://bw.example.com" ]] +} + +@test "BW_DIRECTORY_TYPE env var causes bwdc config directory to be called" { + local cid + cid=$(docker run -d \ + -v "$FIXTURES/mock_bwdc.sh:/usr/local/bin/bwdc" \ + -v "$FIXTURES/data.json:$CONFIG_PATH" \ + -e BW_DIRECTORY_TYPE=0 \ + -e SYNC_INTERVAL_MIN=60 \ + "$IMAGE") + + sleep 8 + + run docker exec "$cid" cat /tmp/bwdc_calls + docker rm -f "$cid" >/dev/null 2>&1 + [ "$status" -eq 0 ] + [[ "$output" =~ "config directory 0" ]] +} + +@test "BW_DIRECTORY_KEY with ldap type calls bwdc config ldap.password" { + local cid + cid=$(docker run -d \ + -v "$FIXTURES/mock_bwdc.sh:/usr/local/bin/bwdc" \ + -v "$FIXTURES/data.json:$CONFIG_PATH" \ + -e BW_DIRECTORY_TYPE=0 \ + -e BW_DIRECTORY_KEY=supersecret \ + -e SYNC_INTERVAL_MIN=60 \ + "$IMAGE") + + sleep 8 + + run docker exec "$cid" cat /tmp/bwdc_calls + docker rm -f "$cid" >/dev/null 2>&1 + [ "$status" -eq 0 ] + [[ "$output" =~ "config ldap.password supersecret" ]] +} + +@test "BW_DIRECTORY_KEY with azure type (numeric) calls bwdc config azure.key" { + local cid + cid=$(docker run -d \ + -v "$FIXTURES/mock_bwdc.sh:/usr/local/bin/bwdc" \ + -v "$FIXTURES/data.json:$CONFIG_PATH" \ + -e BW_DIRECTORY_TYPE=1 \ + -e BW_DIRECTORY_KEY=my-azure-secret \ + -e SYNC_INTERVAL_MIN=60 \ + "$IMAGE") + + sleep 8 + + run docker exec "$cid" cat /tmp/bwdc_calls + docker rm -f "$cid" >/dev/null 2>&1 + [ "$status" -eq 0 ] + [[ "$output" =~ "config azure.key my-azure-secret" ]] +} + +@test "BW_DIRECTORY_KEY with azure type (string) calls bwdc config azure.key" { + local cid + cid=$(docker run -d \ + -v "$FIXTURES/mock_bwdc.sh:/usr/local/bin/bwdc" \ + -v "$FIXTURES/data.json:$CONFIG_PATH" \ + -e BW_DIRECTORY_TYPE=azure \ + -e BW_DIRECTORY_KEY=my-azure-secret \ + -e SYNC_INTERVAL_MIN=60 \ + "$IMAGE") + + sleep 8 + + run docker exec "$cid" cat /tmp/bwdc_calls + docker rm -f "$cid" >/dev/null 2>&1 + [ "$status" -eq 0 ] + [[ "$output" =~ "config azure.key my-azure-secret" ]] +} + +@test "azure sync: bwdc config directory is set to type 1" { + local cid + cid=$(docker run -d \ + -v "$FIXTURES/mock_bwdc.sh:/usr/local/bin/bwdc" \ + -v "$FIXTURES/data.json:$CONFIG_PATH" \ + -e BW_DIRECTORY_TYPE=1 \ + -e BW_DIRECTORY_KEY=my-azure-secret \ + -e SYNC_INTERVAL_MIN=60 \ + "$IMAGE") + + sleep 8 + + run docker exec "$cid" cat /tmp/bwdc_calls + docker rm -f "$cid" >/dev/null 2>&1 + [ "$status" -eq 0 ] + [[ "$output" =~ "config directory 1" ]] + [[ "$output" =~ "config azure.key my-azure-secret" ]] + [[ "$output" =~ "sync" ]] +} + +@test "bwdc sync is called during the sync loop" { + local cid + cid=$(docker run -d \ + -v "$FIXTURES/mock_bwdc.sh:/usr/local/bin/bwdc" \ + -v "$FIXTURES/data.json:$CONFIG_PATH" \ + -e SYNC_INTERVAL_MIN=60 \ + "$IMAGE") + + sleep 8 + + run docker exec "$cid" cat /tmp/bwdc_calls + docker rm -f "$cid" >/dev/null 2>&1 + [ "$status" -eq 0 ] + [[ "$output" =~ "sync" ]] +} diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100755 index 0000000..c88a798 --- /dev/null +++ b/tests/run_tests.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" + +if ! command -v bats &>/dev/null; then + echo "bats not found — install it: https://github.com/bats-core/bats-core" + echo " npm install -g bats OR brew install bats-core" + exit 1 +fi + +echo "=== Unit tests ===" +bats "$TESTS_DIR/unit/" + +echo "" +echo "=== Integration tests (requires Docker) ===" +bats "$TESTS_DIR/integration/" diff --git a/tests/unit/healthcheck.bats b/tests/unit/healthcheck.bats new file mode 100644 index 0000000..8071a0a --- /dev/null +++ b/tests/unit/healthcheck.bats @@ -0,0 +1,58 @@ +#!/usr/bin/env bats + +HEALTHCHECK="$BATS_TEST_DIRNAME/../../healthcheck.sh" + +setup() { + export HEARTBEAT_FILE + HEARTBEAT_FILE="$(mktemp)" + export SYNC_INTERVAL_MIN=5 +} + +teardown() { + rm -f "$HEARTBEAT_FILE" +} + +@test "healthy when heartbeat is fresh" { + date +%s > "$HEARTBEAT_FILE" + run "$HEALTHCHECK" + [ "$status" -eq 0 ] + [[ "$output" =~ "OK:" ]] +} + +@test "unhealthy when heartbeat file is missing" { + rm -f "$HEARTBEAT_FILE" + run "$HEALTHCHECK" + [ "$status" -eq 1 ] + [[ "$output" =~ "UNHEALTHY" ]] +} + +@test "unhealthy when heartbeat is too old" { + echo "1" > "$HEARTBEAT_FILE" + run "$HEALTHCHECK" + [ "$status" -eq 1 ] + [[ "$output" =~ "UNHEALTHY" ]] +} + +@test "unhealthy when heartbeat exceeds 2x interval plus buffer" { + # MAX_AGE_SEC = 1*60*2+60 = 180; write a timestamp 181s ago + export SYNC_INTERVAL_MIN=1 + echo "$(($(date +%s) - 181))" > "$HEARTBEAT_FILE" + run "$HEALTHCHECK" + [ "$status" -eq 1 ] + [[ "$output" =~ "UNHEALTHY" ]] +} + +@test "healthy when heartbeat is within 2x interval plus buffer" { + export SYNC_INTERVAL_MIN=1 + # 60s ago is within 180s max + echo "$(($(date +%s) - 60))" > "$HEARTBEAT_FILE" + run "$HEALTHCHECK" + [ "$status" -eq 0 ] +} + +@test "output includes age in seconds" { + date +%s > "$HEARTBEAT_FILE" + run "$HEALTHCHECK" + [ "$status" -eq 0 ] + [[ "$output" =~ "ago" ]] +}