#!/usr/bin/env bash # # Smoke test for the organization Public API member and group write endpoints. # # Boots a throwaway Vaultwarden instance against a temporary SQLite database # seeded with two organizations, mints an organization API token for the first # org, then exercises every write endpoint and asserts on the resulting state. # # Beyond the happy paths it asserts the guards that protect organization # integrity: the last confirmed owner cannot be demoted, revoked or deleted; # collections, groups and members from another organization are rejected; ids # belonging to the second organization return HTTP 404; and a request with no # token returns HTTP 401. It also asserts that a group update leaves member # assignments alone, and that every write is recorded in the event log with no # acting user, since a Public API client is not a user. # # 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_write.sh # VW_BIN=/path/to/vaultwarden PORT=8123 scripts/smoke_public_api_write.sh set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) cd "$REPO_ROOT" PORT="${PORT:-8082}" 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 USER3=cccccccc-cccc-4ccc-8ccc-cccccccccccc # MEMBER is the only confirmed owner, so it is the one the guards protect. MEMBER=33333333-3333-4333-8333-333333333333 MEMBER2=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa MEMBER3=dddddddd-dddd-4ddd-8ddd-dddddddddddd 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 NEW_EMAIL=newmember@example.com # ---- 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 ORG_EVENTS_ENABLED=true export INVITATIONS_ALLOWED=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 } # reqj METHOD PATH TOKEN JSON -> same, with a JSON request body reqj() { local method="$1" path="$2" token="$3" body="$4" if [ -n "$token" ]; then HTTP_CODE=$(curl -sS -o "$TMP/body" -w '%{http_code}' \ -X "$method" -H "Authorization: Bearer $token" \ -H 'Content-Type: application/json' -d "$body" "$API$path") else HTTP_CODE=$(curl -sS -o "$TMP/body" -w '%{http_code}' \ -X "$method" -H 'Content-Type: application/json' -d "$body" "$API$path") fi } jqval() { jq -r "$1" "$TMP/body"; } jqcheck() { # label filter expected check_eq "$1" "$(jqval "$2")" "$3" } sqlcheck() { # label sql expected check_eq "$1" "$(sqlite3 "$TMP/db.sqlite3" "$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 "== Create a group ==" reqj POST "/api/public/groups" "$TOKEN" \ "{\"name\":\"Platform\",\"externalId\":\"ext-new-group\",\"collections\":[{\"id\":\"$COLLECTION\",\"readOnly\":true,\"hidePasswords\":false,\"manage\":false}]}" check_eq "create group -> 200" "$HTTP_CODE" "200" jqcheck "created group discriminator" '.object' "group" jqcheck "created group name" '.name' "Platform" jqcheck "created group externalId" '.externalId' "ext-new-group" jqcheck "created group accessAll defaults to false" '.accessAll' "false" NEWGROUP=$(jqval '.id') req GET "/api/public/groups/$NEWGROUP" "$TOKEN" check_eq "created group is readable -> 200" "$HTTP_CODE" "200" jqcheck "created group kept its collection grant" '.collections | length' "1" jqcheck "created group collection id" '.collections[0].id' "$COLLECTION" jqcheck "created group collection readOnly" '.collections[0].readOnly' "true" echo "" echo "== Group input validation ==" reqj POST "/api/public/groups" "$TOKEN" \ "{\"name\":\"Bad\",\"collections\":[{\"id\":\"$COLLECTION2\"}]}" check_eq "group with another org's collection -> 400" "$HTTP_CODE" "400" echo "" echo "== Update a group ==" reqj PUT "/api/public/groups/$NEWGROUP" "$TOKEN" \ "{\"name\":\"Platform Team\",\"externalId\":\"ext-new-group-2\",\"collections\":[]}" check_eq "update group -> 200" "$HTTP_CODE" "200" jqcheck "updated group name" '.name' "Platform Team" jqcheck "updated group externalId" '.externalId' "ext-new-group-2" req GET "/api/public/groups/$NEWGROUP" "$TOKEN" jqcheck "update replaced the collection grants" '.collections | length' "0" echo "" echo "== Group member ids ==" reqj PUT "/api/public/groups/$NEWGROUP/member-ids" "$TOKEN" "{\"memberIds\":[\"$MEMBER3\"]}" check_eq "set group member-ids -> 200" "$HTTP_CODE" "200" req GET "/api/public/groups/$NEWGROUP/member-ids" "$TOKEN" jqcheck "group has one member" 'length' "1" jqcheck "group member is the expected membership" '.[0]' "$MEMBER3" reqj PUT "/api/public/groups/$NEWGROUP/member-ids" "$TOKEN" "{\"memberIds\":[\"$MEMBER2\"]}" check_eq "group member-ids from another org -> 400" "$HTTP_CODE" "400" # Members are owned by the member-ids endpoint, so a group update must leave # them alone. The internal endpoint clears them, this one deliberately does not. reqj PUT "/api/public/groups/$NEWGROUP" "$TOKEN" "{\"name\":\"Platform Team\",\"collections\":[]}" check_eq "update group again -> 200" "$HTTP_CODE" "200" req GET "/api/public/groups/$NEWGROUP/member-ids" "$TOKEN" jqcheck "group update left member assignments intact" 'length' "1" echo "" echo "== accessAll survives an update that omits it ==" reqj POST "/api/public/groups" "$TOKEN" "{\"name\":\"Full Access\",\"accessAll\":true,\"collections\":[]}" check_eq "create an accessAll group -> 200" "$HTTP_CODE" "200" jqcheck "created group has accessAll" '.accessAll' "true" AAGROUP=$(jqval '.id') reqj PUT "/api/public/groups/$AAGROUP" "$TOKEN" "{\"name\":\"Full Access Renamed\",\"collections\":[]}" check_eq "rename without accessAll -> 200" "$HTTP_CODE" "200" jqcheck "omitted accessAll is preserved" '.accessAll' "true" req DELETE "/api/public/groups/$AAGROUP" "$TOKEN" check_eq "clean up the accessAll group -> 200" "$HTTP_CODE" "200" echo "" echo "== Create a member ==" reqj POST "/api/public/members" "$TOKEN" \ "{\"email\":\"$NEW_EMAIL\",\"type\":2,\"externalId\":\"ext-new-member\",\"collections\":[{\"id\":\"$COLLECTION\",\"readOnly\":true,\"hidePasswords\":false,\"manage\":false}],\"groups\":[\"$GROUP\"]}" check_eq "create member -> 200" "$HTTP_CODE" "200" jqcheck "created member discriminator" '.object' "member" jqcheck "created member email" '.email' "$NEW_EMAIL" jqcheck "created member type" '.type' "2" jqcheck "created member externalId" '.externalId' "ext-new-member" jqcheck "created member is invited" '.status' "0" NEWMEMBER=$(jqval '.id') req GET "/api/public/members/$NEWMEMBER" "$TOKEN" jqcheck "created member kept its collection grant" '.collections | length' "1" jqcheck "created member collection readOnly" '.collections[0].readOnly' "true" req GET "/api/public/members/$NEWMEMBER/group-ids" "$TOKEN" jqcheck "created member joined the group" 'length' "1" jqcheck "created member group id" '.[0]' "$GROUP" echo "" echo "== Member input validation ==" reqj POST "/api/public/members" "$TOKEN" "{\"email\":\"$NEW_EMAIL\",\"type\":2}" check_eq "duplicate member email -> 400" "$HTTP_CODE" "400" reqj POST "/api/public/members" "$TOKEN" \ "{\"email\":\"other@example.com\",\"type\":2,\"groups\":[\"$GROUP2\"]}" check_eq "member with another org's group -> 400" "$HTTP_CODE" "400" reqj POST "/api/public/members" "$TOKEN" "{\"email\":\"bad@example.com\",\"type\":99}" check_eq "member with an unknown type -> 400" "$HTTP_CODE" "400" echo "" echo "== Update a member ==" # An omitted groups list must leave group membership alone, and an omitted externalId # must not clear the directory matching key. reqj PUT "/api/public/members/$NEWMEMBER" "$TOKEN" \ "{\"type\":2,\"collections\":[{\"id\":\"$COLLECTION\",\"readOnly\":false,\"hidePasswords\":true,\"manage\":false}]}" check_eq "update member -> 200" "$HTTP_CODE" "200" jqcheck "omitted externalId is preserved" '.externalId' "ext-new-member" req GET "/api/public/members/$NEWMEMBER" "$TOKEN" jqcheck "update replaced the collection grants" '.collections | length' "1" jqcheck "updated collection readOnly" '.collections[0].readOnly' "false" jqcheck "updated collection hidePasswords" '.collections[0].hidePasswords' "true" req GET "/api/public/members/$NEWMEMBER/group-ids" "$TOKEN" jqcheck "omitted groups leave membership alone" 'length' "1" # An explicit empty list does clear them. reqj PUT "/api/public/members/$NEWMEMBER" "$TOKEN" \ "{\"type\":2,\"externalId\":\"ext-updated\",\"collections\":[],\"groups\":[]}" check_eq "update member with explicit empty groups -> 200" "$HTTP_CODE" "200" jqcheck "explicit externalId is applied" '.externalId' "ext-updated" req GET "/api/public/members/$NEWMEMBER/group-ids" "$TOKEN" jqcheck "explicit empty groups clears membership" 'length' "0" echo "" echo "== Member group ids ==" reqj PUT "/api/public/members/$NEWMEMBER/group-ids" "$TOKEN" "{\"groupIds\":[\"$GROUP\"]}" check_eq "set member group-ids -> 200" "$HTTP_CODE" "200" req GET "/api/public/members/$NEWMEMBER/group-ids" "$TOKEN" jqcheck "member group-ids applied" '.[0]' "$GROUP" reqj PUT "/api/public/members/$NEWMEMBER/group-ids" "$TOKEN" "{\"groupIds\":[\"$GROUP2\"]}" check_eq "member group-ids from another org -> 400" "$HTTP_CODE" "400" echo "" echo "== Reinvite ==" req POST "/api/public/members/$NEWMEMBER/reinvite" "$TOKEN" check_eq "reinvite an invited member -> 200" "$HTTP_CODE" "200" echo "" echo "== Revoke and restore ==" req POST "/api/public/members/$MEMBER3/revoke" "$TOKEN" check_eq "revoke a member -> 200" "$HTTP_CODE" "200" req GET "/api/public/members/$MEMBER3" "$TOKEN" jqcheck "revoked member reports the upstream revoked status" '.status' "-1" req POST "/api/public/members/$MEMBER3/revoke" "$TOKEN" check_eq "revoking twice -> 400" "$HTTP_CODE" "400" req POST "/api/public/members/$MEMBER3/restore" "$TOKEN" check_eq "restore a member -> 200" "$HTTP_CODE" "200" req GET "/api/public/members/$MEMBER3" "$TOKEN" jqcheck "restored member is confirmed again" '.status' "2" req POST "/api/public/members/$MEMBER3/restore" "$TOKEN" check_eq "restoring an active member -> 400" "$HTTP_CODE" "400" req POST "/api/public/members/$NEWMEMBER/restore" "$TOKEN" check_eq "restoring an invited member -> 400" "$HTTP_CODE" "400" echo "" echo "== A manage-all member survives a read-modify-write ==" # Vaultwarden stores manage-all as the access_all flag on a Manager. It is exposed as the # custom role plus its three collection permissions, which is the only shape a client can # read and send back without silently dropping the access. PERMS='{"accessEventLogs":false,"accessImportExport":false,"accessReports":false,"createNewCollections":true,"editAnyCollection":true,"deleteAnyCollection":true,"manageGroups":false,"managePolicies":false,"manageSso":false,"manageUsers":false,"manageResetPassword":false,"manageScim":false}' reqj POST "/api/public/members" "$TOKEN" \ "{\"email\":\"manageall@example.com\",\"type\":4,\"permissions\":$PERMS}" check_eq "create a manage-all member -> 200" "$HTTP_CODE" "200" MANAGEALL=$(jqval '.id') jqcheck "manage-all member reports the custom role" '.type' "4" jqcheck "manage-all member reports its permissions" '.permissions.editAnyCollection' "true" # Read it back and send exactly that back again, which is what a sync client does. req GET "/api/public/members/$MANAGEALL" "$TOKEN" jqcheck "manage-all member still reports the custom role" '.type' "4" ROUNDTRIP=$(jq -c '{type: .type, externalId: .externalId, permissions: .permissions}' "$TMP/body") reqj PUT "/api/public/members/$MANAGEALL" "$TOKEN" "$ROUNDTRIP" check_eq "write the member back unchanged -> 200" "$HTTP_CODE" "200" req GET "/api/public/members/$MANAGEALL" "$TOKEN" jqcheck "round-trip kept the custom role" '.type' "4" jqcheck "round-trip kept manage-all" '.permissions.editAnyCollection' "true" # A plain member carries no permissions object. req GET "/api/public/members/$MEMBER3" "$TOKEN" jqcheck "a plain member has null permissions" '.permissions' "null" req DELETE "/api/public/members/$MANAGEALL" "$TOKEN" check_eq "clean up the manage-all member -> 200" "$HTTP_CODE" "200" echo "" echo "== Ownership is out of reach for a Public API client ==" reqj POST "/api/public/members" "$TOKEN" "{\"email\":\"owner@example.com\",\"type\":0}" check_eq "creating an Owner -> 400" "$HTTP_CODE" "400" reqj PUT "/api/public/members/$MEMBER3" "$TOKEN" "{\"type\":0}" check_eq "promoting a member to Owner -> 400" "$HTTP_CODE" "400" req GET "/api/public/members/$MEMBER3" "$TOKEN" jqcheck "the member was not promoted" '.type' "2" reqj PUT "/api/public/members/$MEMBER" "$TOKEN" "{\"type\":2}" check_eq "demoting an owner -> 400" "$HTTP_CODE" "400" req DELETE "/api/public/members/$MEMBER" "$TOKEN" check_eq "deleting an owner -> 400" "$HTTP_CODE" "400" req POST "/api/public/members/$MEMBER/revoke" "$TOKEN" check_eq "revoking an owner -> 400" "$HTTP_CODE" "400" req GET "/api/public/members/$MEMBER" "$TOKEN" jqcheck "the owner is untouched, type" '.type' "0" jqcheck "the owner is untouched, status" '.status' "2" echo "" echo "== Organization scoping boundary ==" reqj PUT "/api/public/members/$MEMBER2" "$TOKEN" "{\"type\":2}" check_eq "updating a member of another org -> 404" "$HTTP_CODE" "404" req DELETE "/api/public/members/$MEMBER2" "$TOKEN" check_eq "deleting a member of another org -> 404" "$HTTP_CODE" "404" req POST "/api/public/members/$MEMBER2/revoke" "$TOKEN" check_eq "revoking a member of another org -> 404" "$HTTP_CODE" "404" reqj PUT "/api/public/groups/$GROUP2" "$TOKEN" "{\"name\":\"Hijacked\"}" check_eq "updating a group of another org -> 404" "$HTTP_CODE" "404" req DELETE "/api/public/groups/$GROUP2" "$TOKEN" check_eq "deleting a group of another org -> 404" "$HTTP_CODE" "404" echo "" echo "== Delete ==" req DELETE "/api/public/members/$NEWMEMBER" "$TOKEN" check_eq "delete member -> 200" "$HTTP_CODE" "200" req GET "/api/public/members/$NEWMEMBER" "$TOKEN" check_eq "deleted member is gone -> 404" "$HTTP_CODE" "404" req DELETE "/api/public/groups/$NEWGROUP" "$TOKEN" check_eq "delete group -> 200" "$HTTP_CODE" "200" req GET "/api/public/groups/$NEWGROUP" "$TOKEN" check_eq "deleted group is gone -> 404" "$HTTP_CODE" "404" echo "" echo "== Authentication required ==" reqj POST "/api/public/groups" "" "{\"name\":\"NoToken\"}" check_eq "create group with no token -> 401" "$HTTP_CODE" "401" req DELETE "/api/public/members/$MEMBER3" check_eq "delete member with no token -> 401" "$HTTP_CODE" "401" req GET "/api/public/members/$MEMBER3" "$TOKEN" check_eq "the unauthenticated delete changed nothing" "$HTTP_CODE" "200" echo "" echo "== Writes are recorded in the event log without an acting user ==" stop_server # 1400 GroupCreated, 1401 GroupUpdated, 1402 GroupDeleted, # 1500 OrganizationUserInvited, 1502 OrganizationUserUpdated, # 1503 OrganizationUserRemoved, 1511 Revoked, 1512 Restored. for pair in "1400:group created" "1401:group updated" "1402:group deleted" \ "1500:member invited" "1502:member updated" "1503:member removed" \ "1511:member revoked" "1512:member restored"; do code="${pair%%:*}" label="${pair#*:}" got=$(sqlite3 "$TMP/db.sqlite3" \ "SELECT COUNT(*) > 0 FROM event WHERE org_uuid='$ORG' AND event_type=$code;") check_eq "event logged: $label" "$got" "1" done sqlcheck "no Public API event records an acting user" \ "SELECT COUNT(*) FROM event WHERE org_uuid='$ORG' AND act_user_uuid IS NOT NULL;" "0" sqlcheck "no Public API event records a device type" \ "SELECT COUNT(*) FROM event WHERE org_uuid='$ORG' AND device_type IS NOT NULL;" "0" sqlcheck "Public API events still record the client address" \ "SELECT COUNT(*) FROM event WHERE org_uuid='$ORG' AND ip_address IS NULL;" "0" echo "" if [ "$FAILS" -ne 0 ]; then echo "RESULT: $FAILS assertion(s) failed." exit 1 fi echo "RESULT: all assertions passed."