#!/usr/bin/env bash # # Smoke test for the organization Public API read endpoints. # # Boots a throwaway Vaultwarden instance against a temporary SQLite database # seeded with two organizations (each with its own members, groups, collections # and access associations), mints an organization API token for the first org, # then exercises every read endpoint and asserts on the response shapes. # # It also asserts the organization scoping boundary: ids that belong to the # second organization must return HTTP 404 through the first org's token, and a # request with no token must return HTTP 401. # # The script exits non-zero if any assertion fails, so it is usable as a check. # # Requirements: bash, curl, jq, sqlite3, and either a prebuilt binary passed via # the VW_BIN environment variable or a cargo toolchain to build one. # # Usage: # scripts/smoke_public_api.sh # VW_BIN=/path/to/vaultwarden PORT=8123 scripts/smoke_public_api.sh set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) cd "$REPO_ROOT" PORT="${PORT:-8079}" VW_BIN="${VW_BIN:-$REPO_ROOT/target/debug/vaultwarden}" API="http://127.0.0.1:$PORT" # ---- fixtures ------------------------------------------------------------- ORG=22222222-2222-4222-8222-222222222222 ORG2=99999999-9999-4999-8999-999999999999 USER=11111111-1111-4111-8111-111111111111 USER2=88888888-8888-4888-8888-888888888888 MEMBER=33333333-3333-4333-8333-333333333333 MEMBER2=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa GROUP=44444444-4444-4444-8444-444444444444 GROUP2=bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb COLLECTION=55555555-5555-4555-8555-555555555555 COLLECTION2=66666666-6666-4666-8666-666666666666 APIKEYUUID=77777777-7777-4777-8777-777777777777 APIKEY=smoketestapikey1234567890 # ---- prerequisites -------------------------------------------------------- for tool in curl jq sqlite3; do if ! command -v "$tool" >/dev/null 2>&1; then echo "ERROR: required tool '$tool' is not installed" >&2 exit 2 fi done if [ ! -x "$VW_BIN" ]; then if command -v cargo >/dev/null 2>&1; then echo "Building vaultwarden (sqlite feature); this can take a while..." cargo build --features sqlite else echo "ERROR: no binary at '$VW_BIN' and no cargo toolchain to build one." >&2 echo "Set VW_BIN to a prebuilt binary or install a Rust toolchain." >&2 exit 2 fi fi # ---- workspace + cleanup -------------------------------------------------- TMP=$(mktemp -d) SERVER_PID="" cleanup() { if [ -n "$SERVER_PID" ]; then kill "$SERVER_PID" >/dev/null 2>&1 || true wait "$SERVER_PID" 2>/dev/null || true fi rm -rf "$TMP" } trap cleanup EXIT export DATA_FOLDER="$TMP" export DATABASE_URL="sqlite://$TMP/db.sqlite3" export ADMIN_TOKEN="smoketestadmintoken" export ORG_GROUPS_ENABLED=true export WEB_VAULT_ENABLED=false export ROCKET_PORT="$PORT" export ROCKET_ADDRESS=127.0.0.1 export DOMAIN="http://localhost:$PORT" # ---- server helpers ------------------------------------------------------- start_server() { local logfile="$1" "$VW_BIN" >"$logfile" 2>&1 & SERVER_PID=$! local i for i in $(seq 1 90); do if grep -q "Rocket has launched" "$logfile" 2>/dev/null; then return 0 fi if ! kill -0 "$SERVER_PID" 2>/dev/null; then echo "ERROR: server exited during startup. Log:" >&2 cat "$logfile" >&2 return 1 fi sleep 1 done echo "ERROR: server did not launch within 90s. Log:" >&2 cat "$logfile" >&2 return 1 } stop_server() { if [ -n "$SERVER_PID" ]; then kill "$SERVER_PID" >/dev/null 2>&1 || true wait "$SERVER_PID" 2>/dev/null || true SERVER_PID="" fi } # ---- assertion helpers ---------------------------------------------------- FAILS=0 pass() { printf 'PASS: %s\n' "$1"; } fail() { printf 'FAIL: %s\n' "$1"; FAILS=$((FAILS + 1)); } check_eq() { # label actual expected if [ "$2" = "$3" ]; then pass "$1" else fail "$1 (expected [$3], got [$2])" fi } # req METHOD PATH [TOKEN] -> sets HTTP_CODE, body written to $TMP/body req() { local method="$1" path="$2" token="${3:-}" if [ -n "$token" ]; then HTTP_CODE=$(curl -sS -o "$TMP/body" -w '%{http_code}' \ -X "$method" -H "Authorization: Bearer $token" "$API$path") else HTTP_CODE=$(curl -sS -o "$TMP/body" -w '%{http_code}' -X "$method" "$API$path") fi } jqval() { jq -r "$1" "$TMP/body"; } jqcheck() { # label filter expected check_eq "$1" "$(jqval "$2")" "$3" } # ---- boot once to run migrations, then seed, then boot to serve ----------- echo "== Booting once to create the database schema ==" start_server "$TMP/boot1.log" stop_server echo "== Seeding two organizations with members, groups and collections ==" sqlite3 "$TMP/db.sqlite3" <&2 exit 1 fi pass "minted organization API token" echo "" echo "== Read endpoints ==" # 1. Members list (without collections/groups). req GET "/api/public/members" "$TOKEN" check_eq "members list -> 200" "$HTTP_CODE" "200" jqcheck "members list is a list object" '.object' "list" jqcheck "members list has continuationToken null" '.continuationToken' "null" jqcheck "members list has one member" '.data | length' "1" jqcheck "member object discriminator" '.data[0].object' "member" jqcheck "member id" '.data[0].id' "$MEMBER" jqcheck "member userId" '.data[0].userId' "$USER" jqcheck "member email" '.data[0].email' "alice@example.com" jqcheck "member name" '.data[0].name' "Alice Example" jqcheck "member type" '.data[0].type' "2" jqcheck "member status" '.data[0].status' "2" jqcheck "member externalId" '.data[0].externalId' "ext-member-1" jqcheck "member resetPasswordEnrolled" '.data[0].resetPasswordEnrolled' "false" jqcheck "members list omits collections" '.data[0] | has("collections")' "false" # 2. Member detail (with direct collection grants). req GET "/api/public/members/$MEMBER" "$TOKEN" check_eq "member detail -> 200" "$HTTP_CODE" "200" jqcheck "member detail id" '.id' "$MEMBER" jqcheck "member detail carries one collection grant" '.collections | length' "1" jqcheck "member detail collection id" '.collections[0].id' "$COLLECTION" jqcheck "member detail collection readOnly" '.collections[0].readOnly' "true" jqcheck "member detail collection hidePasswords" '.collections[0].hidePasswords' "false" jqcheck "member detail collection manage" '.collections[0].manage' "false" # 3. Member group ids. req GET "/api/public/members/$MEMBER/group-ids" "$TOKEN" check_eq "member group-ids -> 200" "$HTTP_CODE" "200" jqcheck "member group-ids is a bare array of one" 'length' "1" jqcheck "member group-ids contains the group" '.[0]' "$GROUP" # 4. Groups list. req GET "/api/public/groups" "$TOKEN" check_eq "groups list -> 200" "$HTTP_CODE" "200" jqcheck "groups list is a list object" '.object' "list" jqcheck "groups list has one group" '.data | length' "1" jqcheck "group object discriminator" '.data[0].object' "group" jqcheck "group id" '.data[0].id' "$GROUP" jqcheck "group name is plaintext" '.data[0].name' "Engineering" jqcheck "group accessAll" '.data[0].accessAll' "false" jqcheck "group externalId" '.data[0].externalId' "ext-group-1" # 5. Group detail (with collection grants). req GET "/api/public/groups/$GROUP" "$TOKEN" check_eq "group detail -> 200" "$HTTP_CODE" "200" jqcheck "group detail id" '.id' "$GROUP" jqcheck "group detail carries one collection grant" '.collections | length' "1" jqcheck "group detail collection id" '.collections[0].id' "$COLLECTION" jqcheck "group detail collection readOnly" '.collections[0].readOnly' "false" jqcheck "group detail collection manage" '.collections[0].manage' "true" # 6. Group member ids. req GET "/api/public/groups/$GROUP/member-ids" "$TOKEN" check_eq "group member-ids -> 200" "$HTTP_CODE" "200" jqcheck "group member-ids is a bare array of one" 'length' "1" jqcheck "group member-ids contains the membership" '.[0]' "$MEMBER" # 7. Collections list (id + externalId only, no name). req GET "/api/public/collections" "$TOKEN" check_eq "collections list -> 200" "$HTTP_CODE" "200" jqcheck "collections list is a list object" '.object' "list" jqcheck "collections list has one collection" '.data | length' "1" jqcheck "collection object discriminator" '.data[0].object' "collection" jqcheck "collection id" '.data[0].id' "$COLLECTION" jqcheck "collection externalId" '.data[0].externalId' "ext-collection-1" jqcheck "collection omits the encrypted name" '.data[0] | has("name")' "false" # 8. Collection detail (with group grants). req GET "/api/public/collections/$COLLECTION" "$TOKEN" check_eq "collection detail -> 200" "$HTTP_CODE" "200" jqcheck "collection detail id" '.id' "$COLLECTION" jqcheck "collection detail externalId" '.externalId' "ext-collection-1" jqcheck "collection detail omits the encrypted name" 'has("name")' "false" jqcheck "collection detail carries one group grant" '.groups | length' "1" jqcheck "collection detail group id" '.groups[0].id' "$GROUP" jqcheck "collection detail group manage" '.groups[0].manage' "true" echo "" echo "== Organization scoping boundary ==" # Ids that exist but belong to the other organization must not resolve. req GET "/api/public/members/$MEMBER2" "$TOKEN" check_eq "cross-org member -> 404" "$HTTP_CODE" "404" req GET "/api/public/groups/$GROUP2" "$TOKEN" check_eq "cross-org group -> 404" "$HTTP_CODE" "404" req GET "/api/public/collections/$COLLECTION2" "$TOKEN" check_eq "cross-org collection -> 404" "$HTTP_CODE" "404" echo "" echo "== Authentication required ==" req GET "/api/public/members" check_eq "no token -> 401" "$HTTP_CODE" "401" echo "" if [ "$FAILS" -ne 0 ]; then echo "RESULT: $FAILS assertion(s) failed." exit 1 fi echo "RESULT: all assertions passed."