diff --git a/scripts/smoke_public_api_events.sh b/scripts/smoke_public_api_events.sh new file mode 100755 index 00000000..5f39c4a2 --- /dev/null +++ b/scripts/smoke_public_api_events.sh @@ -0,0 +1,332 @@ +#!/usr/bin/env bash +# +# Smoke test for the organization Public API event log endpoint. +# +# Boots a throwaway Vaultwarden instance against a temporary SQLite database +# seeded with two organizations and a handful of events, mints an organization +# API token for the first org, then exercises GET /public/events. +# +# It covers the date range filter, the newest-first ordering, the page size and +# continuation token, the organization scoping boundary, rejection of malformed +# dates, and the empty response returned when org events are disabled. +# +# 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_events.sh +# VW_BIN=/path/to/vaultwarden PORT=8123 scripts/smoke_public_api_events.sh + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) +cd "$REPO_ROOT" + +PORT="${PORT:-8081}" +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 +MEMBER=33333333-3333-4333-8333-333333333333 +GROUP=44444444-4444-4444-8444-444444444444 +APIKEYUUID=77777777-7777-4777-8777-777777777777 +APIKEY=smoketestapikey1234567890 + +# Whole range that covers every seeded event, and a narrow one that does not. +FULL_START=2026-01-01T00:00:00Z +FULL_END=2026-12-31T23:59:59Z +NARROW_START=2026-03-02T12:00:00Z + +# ---- 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_EVENTS_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 +} + +mint_token() { + curl -sS -X POST "$API/identity/connect/token" \ + -d 'grant_type=client_credentials' \ + -d "client_id=organization.$ORG" \ + -d "client_secret=$APIKEY" \ + -d 'scope=api.organization' \ + -d 'device_identifier=dddddddd-dddd-4ddd-8ddd-dddddddddddd' \ + -d 'device_name=smoketest' \ + -d 'device_type=14' | jq -r '.access_token // empty' +} + +# ---- 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 +} + +check_ne() { # label actual not_expected + if [ "$2" != "$3" ]; then + pass "$1" + else + fail "$1 (did not expect [$3])" + 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" +} + +events_url() { # start end [continuationToken] + local url="/api/public/events?start=$1&end=$2" + if [ -n "${3:-}" ]; then + url="$url&continuationToken=$3" + fi + printf '%s' "$url" +} + +# ---- 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 and their events ==" +sqlite3 "$TMP/db.sqlite3" <&2 + exit 1 +fi +pass "minted organization API token" + +echo "" +echo "== Event list ==" + +req GET "$(events_url "$FULL_START" "$FULL_END")" "$TOKEN" +check_eq "events -> 200" "$HTTP_CODE" "200" +jqcheck "events is a list object" '.object' "list" +jqcheck "events returns only this organization's events" '.data | length' "3" +jqcheck "short page has no continuationToken" '.continuationToken' "null" + +# Ordered newest first, so the most recent seeded event comes back first. +jqcheck "newest event first" '.data[0].type' "1500" +jqcheck "newest event organizationId" '.data[0].organizationId' "$ORG" +jqcheck "newest event organizationUserId" '.data[0].organizationUserId' "$MEMBER" +jqcheck "newest event actingUserId" '.data[0].actingUserId' "$USER" +jqcheck "oldest event last" '.data[2].type' "1600" + +# An event with no acting user must still be returned, with a null actingUserId. +jqcheck "actor-less event is returned" '.data[1].type' "1400" +jqcheck "actor-less event has null actingUserId" '.data[1].actingUserId' "null" +jqcheck "actor-less event keeps its groupId" '.data[1].groupId' "$GROUP" +jqcheck "actor-less event has null deviceType" '.data[1].deviceType' "null" + +echo "" +echo "== Date range filter ==" + +req GET "$(events_url "$NARROW_START" "$FULL_END")" "$TOKEN" +check_eq "narrowed range -> 200" "$HTTP_CODE" "200" +jqcheck "narrowed range drops older events" '.data | length' "1" +jqcheck "narrowed range keeps the newest event" '.data[0].type' "1500" + +echo "" +echo "== Malformed dates are rejected, not fatal ==" + +for bad in "start=notadate&end=$FULL_END" "start=$FULL_START&end=notadate"; do + req GET "/api/public/events?$bad" "$TOKEN" + check_ne "malformed date does not fault the server ($bad)" "$HTTP_CODE" "500" + check_eq "malformed date is a client error ($bad)" "$HTTP_CODE" "400" +done + +req GET "$(events_url "$FULL_START" "$FULL_END" notadate)" "$TOKEN" +check_ne "malformed continuationToken does not fault the server" "$HTTP_CODE" "500" +check_eq "malformed continuationToken is a client error" "$HTTP_CODE" "400" + +# The server must still be serving after all of that. +req GET "/alive" +check_eq "server still serving after malformed input" "$HTTP_CODE" "200" + +echo "" +echo "== Page size and continuation token ==" + +# Add enough events to overflow a single page. PAGE_SIZE is 30. +sqlite3 "$TMP/db.sqlite3" < 200" "$HTTP_CODE" "200" +jqcheck "full page is capped at the page size" '.data | length' "30" +check_ne "full page exposes a continuationToken" "$(jqval '.continuationToken')" "null" +jqcheck "continuationToken is the date of the last event on the page" \ + '.continuationToken == (.data[-1].date)' "true" + +# Feeding the token back must walk further back in time, not repeat the page. +NEXT=$(jqval '.continuationToken') +FIRST_DATE=$(jqval '.data[0].date') +req GET "$(events_url "$FULL_START" "$FULL_END" "$NEXT")" "$TOKEN" +check_eq "paged request -> 200" "$HTTP_CODE" "200" +check_ne "second page starts after the first" "$(jqval '.data[0].date')" "$FIRST_DATE" + +echo "" +echo "== Organization scoping boundary ==" + +req GET "$(events_url "$FULL_START" "$FULL_END")" "$TOKEN" +jqcheck "no event belongs to another organization" \ + "[.data[] | select(.organizationId != \"$ORG\")] | length" "0" + +echo "" +echo "== Authentication required ==" + +req GET "$(events_url "$FULL_START" "$FULL_END")" +check_eq "no token -> 401" "$HTTP_CODE" "401" + +req GET "$(events_url "$FULL_START" "$FULL_END")" "not-a-real-token" +check_eq "bogus token -> 401" "$HTTP_CODE" "401" + +echo "" +echo "== Events disabled returns an empty list ==" + +stop_server +export ORG_EVENTS_ENABLED=false +start_server "$TMP/boot3.log" + +TOKEN=$(mint_token) +if [ -z "$TOKEN" ]; then + echo "FAIL: could not mint an organization API token after restart" >&2 + exit 1 +fi + +req GET "$(events_url "$FULL_START" "$FULL_END")" "$TOKEN" +check_eq "events disabled -> 200" "$HTTP_CODE" "200" +jqcheck "events disabled returns an empty list" '.data | length' "0" +jqcheck "events disabled still returns a list object" '.object' "list" + +echo "" +if [ "$FAILS" -ne 0 ]; then + echo "RESULT: $FAILS assertion(s) failed." + exit 1 +fi +echo "RESULT: all assertions passed."