diff --git a/.env.example b/.env.example index f73abdb..762fd76 100644 --- a/.env.example +++ b/.env.example @@ -8,17 +8,24 @@ FMSG_DOMAIN=example.com # Email address for Let's Encrypt certificate registration CERTBOT_EMAIL= -# HMAC secret used to validate JWT tokens for fmsg-webapi -# Prefix with base64: to supply a base64-encoded key (e.g. base64:c2VjcmV0) -FMSG_API_JWT_SECRET=changeme - -# Per-service database passwords (used by application services) -FMSGD_WRITER_PGPASSWORD=changeme -FMSGID_WRITER_PGPASSWORD=changeme +# Base64-encoded Ed25519 seed or private key used by fmsg-webapi to mint +# short-lived first-party JWTs from API keys. Generate with: +# openssl rand -base64 32 +FMSG_API_TOKEN_ED25519_PRIVATE_KEY=changeme + +# Per-service database passwords (used by application services) +FMSGD_WRITER_PGPASSWORD=changeme +FMSGID_WRITER_PGPASSWORD=changeme # ── Optional (defaults shown) ──────────────────────────── # FMSG_PORT=4930 -# FMSGID_PORT=8080 -# GIN_MODE=release -# FMSG_SKIP_DOMAIN_IP_CHECK=false +# FMSGID_PORT=8080 +# GIN_MODE=release +# FMSG_SKIP_DOMAIN_IP_CHECK=false + +# External user JWT login (optional; set all four to enable JWKS auth) +# FMSG_JWT_JWKS_URL=https://idp.example.com/.well-known/jwks.json +# FMSG_JWT_ISSUER=https://idp.example.com/ +# FMSG_JWT_AUDIENCE=fmsg-web-client +# FMSG_JWT_ADDRESS_CLAIM=fmsg_address diff --git a/QUICKSTART.md b/QUICKSTART.md index e51df78..238a8d6 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -46,7 +46,7 @@ Edit `compose/.env` and set at least: ```env FMSG_DOMAIN=example.com CERTBOT_EMAIL= -FMSG_API_JWT_SECRET= +FMSG_API_TOKEN_ED25519_PRIVATE_KEY= FMSGD_WRITER_PGPASSWORD= FMSGID_WRITER_PGPASSWORD= ``` @@ -54,6 +54,7 @@ FMSGID_WRITER_PGPASSWORD= _NOTE_ * FMSG_DOMAIN is the domain part of fmsg addresses e.g. in `@user@example.com` would be `example.com`. This server you are setting up is located at the subdomain `fmsg.` but addresses will be at ``, you should only specify `` for FMSG_DOMAIN here. * CERTBOT_EMAIL is an email address supplied to [Let's Encrypt](https://letsencrypt.org/) for e.g. TLS expiry warnings. +* Generate `FMSG_API_TOKEN_ED25519_PRIVATE_KEY` with `openssl rand -base64 32`. * For all secrets and passwords env vars create your own. Start the stack for the first time from `compose/` and pass the one-time init passwords on the command line (keep these secret, keep them safe): @@ -92,6 +93,19 @@ docker compose cp addresses.csv fmsgid:/opt/fmsgid/data/addresses.csv ### Connect a Client -* Connect a client such as [fmsg-cli](https://github.com/markmnl/fmsg-cli) to `fmsgapi.` configured with your `FMSG_API_JWT_SECRET` to send and retrieve messages. +Create or rotate an API key with the fmsg-webapi operator command, then use it with [fmsg-cli](https://github.com/markmnl/fmsg-cli): -_NOTE_ Anyone with `FMSG_API_JWT_SECRET` can mint tokens for your `fmsgapi.` for any user e.g. `@alice@`. \ No newline at end of file +```sh +docker compose exec fmsg-webapi /opt/fmsg-webapi/fmsg-webapi api-key create-delegation \ + -owner @alice@example.com \ + -agent cli \ + -addr @alice@example.com \ + -cidr 203.0.113.0/24 \ + -expires 2026-12-31T00:00:00Z + +FMSG_API_URL=https://fmsgapi.example.com \ +FMSG_API_KEY=fmsgk__ \ +fmsg list +``` + +The API key plaintext is printed once when created or rotated. Store it securely and pass it to automated clients through `FMSG_API_KEY`. diff --git a/README.md b/README.md index f5c97d9..2415413 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ The compose stack uses Docker named volumes: ``` FMSG_DOMAIN=example.com CERTBOT_EMAIL=admin@example.com - FMSG_API_JWT_SECRET= + FMSG_API_TOKEN_ED25519_PRIVATE_KEY= FMSGD_WRITER_PGPASSWORD= FMSGID_WRITER_PGPASSWORD= ``` @@ -113,7 +113,7 @@ The compose stack uses Docker named volumes: ## Integration Tests -End-to-end tests that spin up two full stacks (`hairpin.local` and `example.com`) on a shared Docker network and exchange messages between them using [fmsg-cli](https://github.com/markmnl/fmsg-cli). +End-to-end tests that spin up two full stacks (`hairpin.local` and `example.com`) on a shared Docker network and exchange messages between them using [fmsg-cli](https://github.com/markmnl/fmsg-cli). The test runner enables fmsg-webapi API-key auth, creates delegated API keys for the test actors during setup, and passes them to fmsg-cli with `FMSG_API_KEY`. **Prerequisites:** Docker, docker compose, Go 1.24+, curl. @@ -128,7 +128,7 @@ End-to-end tests that spin up two full stacks (`hairpin.local` and `example.com` ./test/run-tests.sh cleanup # Refresh local database DD scripts from component branches -FMSGD_REF=main FMSGID_REF=main ./scripts/update-dd.sh +FMSGD_REF=main FMSGID_REF=main FMSG_WEBAPI_REF=main ./scripts/update-dd.sh # CI drift check for database DD scripts ./scripts/update-dd.sh --check @@ -146,12 +146,18 @@ Configure these in `compose/.env`. Variables marked **required** have no default |------------------------------|----------|-----------|----------------------------------------------------------| | `FMSG_DOMAIN` | yes | | The domain name for your fmsg host | | `CERTBOT_EMAIL` | yes | | Email address for Let's Encrypt certificate registration | -| `FMSG_API_JWT_SECRET` | yes | | HMAC secret for fmsg-webapi JWT validation | +| `FMSG_API_TOKEN_ED25519_PRIVATE_KEY` | auth | | Base64 Ed25519 seed/private key used to mint first-party JWTs from API keys | +| `FMSG_JWT_JWKS_URL` | auth | | JWKS endpoint for external RS256 user JWT login | +| `FMSG_JWT_ISSUER` | JWKS | | Expected issuer for external user JWTs | +| `FMSG_JWT_AUDIENCE` | JWKS | | Expected audience for external user JWTs | +| `FMSG_JWT_ADDRESS_CLAIM` | JWKS | | Claim containing the fmsg address | | `FMSG_PORT` | no | `4930` | Host port fmsgd listens on | | `FMSGID_PORT` | no | `8080` | Internal port for the fmsgid API | | `GIN_MODE` | no | `release` | Gin framework mode for fmsgid (`release` or `debug`) | | `FMSG_SKIP_DOMAIN_IP_CHECK` | no | `false` | Skip domain-to-IP validation in fmsgd (useful for dev) | +At least one auth mode is required for fmsg-webapi: API-key auth with `FMSG_API_TOKEN_ED25519_PRIVATE_KEY`, external user JWT auth with the JWKS variables, or both. API keys can be created or rotated with the fmsg-webapi operator command and used by fmsg-cli through `FMSG_API_KEY`. + ### Database The PostgreSQL instance hosts two separate databases (`fmsgd` and `fmsgid`) with dedicated roles per service. @@ -176,6 +182,7 @@ On first startup (empty data volume), PostgreSQL runs the scripts in `docker/pos | `001-init.sh` | Creates roles (with passwords from env) and databases | | `002-fmsgd-dd.sql` | Creates tables and other database objects for fmsgd | | `002-fmsgid-dd.sql` | Creates tables and other database objects for fmsgid | +| `003-fmsg-webapi-dd.sql` | Creates fmsg-webapi API-key grant tables | | `999-permissions.sql`| Grants permissions after all objects exist | > **WARNING:** To re-run initialisation you must remove the `postgres_data` volume. diff --git a/compose/docker-compose.yml b/compose/docker-compose.yml index a8d00e1..bff8971 100644 --- a/compose/docker-compose.yml +++ b/compose/docker-compose.yml @@ -101,11 +101,15 @@ services: CACHEBUST: ${CACHEBUST:-} restart: unless-stopped environment: - FMSG_DOMAIN: ${FMSG_DOMAIN} - FMSG_ID_URL: http://fmsgid:${FMSGID_PORT:-8080} - FMSG_API_JWT_SECRET: ${FMSG_API_JWT_SECRET} - FMSG_API_PORT: ${FMSG_API_PORT:-8000} - PGHOST: postgres + FMSG_DOMAIN: ${FMSG_DOMAIN} + FMSG_ID_URL: http://fmsgid:${FMSGID_PORT:-8080} + FMSG_API_TOKEN_ED25519_PRIVATE_KEY: ${FMSG_API_TOKEN_ED25519_PRIVATE_KEY:-} + FMSG_JWT_JWKS_URL: ${FMSG_JWT_JWKS_URL:-} + FMSG_JWT_ISSUER: ${FMSG_JWT_ISSUER:-} + FMSG_JWT_AUDIENCE: ${FMSG_JWT_AUDIENCE:-} + FMSG_JWT_ADDRESS_CLAIM: ${FMSG_JWT_ADDRESS_CLAIM:-} + FMSG_API_PORT: ${FMSG_API_PORT:-8000} + PGHOST: postgres PGPORT: 5432 PGDATABASE: fmsgd PGUSER: fmsgd_writer diff --git a/docker/fmsg-webapi/Dockerfile b/docker/fmsg-webapi/Dockerfile index 0b44def..56aa041 100644 --- a/docker/fmsg-webapi/Dockerfile +++ b/docker/fmsg-webapi/Dockerfile @@ -5,17 +5,16 @@ ARG CACHEBUST WORKDIR /build -RUN git clone --branch "$FMSG_WEBAPI_REF" --depth 1 https://github.com/markmnl/fmsg-webapi.git . && \ - cd src && \ - go build -o fmsg-webapi . - -FROM debian:bookworm-slim +RUN git clone --branch "$FMSG_WEBAPI_REF" --depth 1 https://github.com/markmnl/fmsg-webapi.git . && \ + go build -o fmsg-webapi ./cmd/fmsg-webapi + +FROM debian:bookworm-slim RUN useradd -r -s /bin/false fmsg WORKDIR /opt/fmsg-webapi -COPY --from=builder /build/src/fmsg-webapi /opt/fmsg-webapi/fmsg-webapi +COPY --from=builder /build/fmsg-webapi /opt/fmsg-webapi/fmsg-webapi RUN chown -R fmsg:fmsg /opt/fmsg-webapi diff --git a/docker/postgres/init/003-fmsg-webapi-dd.sql b/docker/postgres/init/003-fmsg-webapi-dd.sql new file mode 100644 index 0000000..463a5e8 --- /dev/null +++ b/docker/postgres/init/003-fmsg-webapi-dd.sql @@ -0,0 +1,66 @@ +\connect fmsgd + +-- fmsg-webapi database extensions. +-- +-- This file extends the fmsgd schema at: +-- https://github.com/markmnl/fmsgd/blob/main/dd.sql +-- +-- fmsg-webapi assumes fmsgd is the fmsg host implementation and uses the same +-- PostgreSQL database. + +CREATE TABLE IF NOT EXISTS fmsg_api_sub_account ( + owner_addr varchar(255) NOT NULL, + agent varchar(64) NOT NULL, + sub_addr varchar(255), + grant_type text NOT NULL DEFAULT 'derived_sub_account', + display_name text, + key_id varchar(64), + key_hash bytea, + allowed_cidrs cidr[], + key_expires_at timestamptz, + max_sub_accounts int NOT NULL DEFAULT 5, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (owner_addr, agent), + UNIQUE (key_id), + CHECK (max_sub_accounts > 0), + CHECK (grant_type IN ('derived_sub_account', 'delegated_identity')), + CHECK ( + (agent = '' AND sub_addr IS NULL AND display_name IS NULL AND key_id IS NULL AND key_hash IS NULL AND allowed_cidrs IS NULL AND key_expires_at IS NULL) + OR + (agent <> '' AND sub_addr IS NOT NULL AND key_id IS NOT NULL AND key_hash IS NOT NULL AND allowed_cidrs IS NOT NULL AND cardinality(allowed_cidrs) > 0 AND key_expires_at IS NOT NULL) + ), + CHECK (agent = '' OR agent NOT LIKE '%\_%' ESCAPE '\') +); + +ALTER TABLE fmsg_api_sub_account + ADD COLUMN IF NOT EXISTS grant_type text NOT NULL DEFAULT 'derived_sub_account'; + +ALTER TABLE fmsg_api_sub_account + ADD COLUMN IF NOT EXISTS display_name text; + +ALTER TABLE fmsg_api_sub_account + DROP CONSTRAINT IF EXISTS fmsg_api_sub_account_sub_addr_key; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fmsg_api_sub_account_grant_type_check' + ) THEN + ALTER TABLE fmsg_api_sub_account + ADD CONSTRAINT fmsg_api_sub_account_grant_type_check + CHECK (grant_type IN ('derived_sub_account', 'delegated_identity')); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS fmsg_api_sub_account_owner_idx + ON fmsg_api_sub_account ((lower(owner_addr))); + +CREATE INDEX IF NOT EXISTS fmsg_api_sub_account_sub_idx + ON fmsg_api_sub_account ((lower(sub_addr))); + +CREATE UNIQUE INDEX IF NOT EXISTS fmsg_api_sub_account_owner_sub_unique + ON fmsg_api_sub_account ((lower(owner_addr)), (lower(sub_addr))) + WHERE agent <> ''; diff --git a/scripts/update-dd.sh b/scripts/update-dd.sh index a29be3b..3defb38 100644 --- a/scripts/update-dd.sh +++ b/scripts/update-dd.sh @@ -10,11 +10,12 @@ usage() { cat <<'EOF' Usage: scripts/update-dd.sh [--check] -Updates local PostgreSQL DD scripts from the fmsgd and fmsgid repositories. +Updates local PostgreSQL DD scripts from the fmsgd, fmsgid and fmsg-webapi repositories. Environment variables: - FMSGD_REF fmsgd branch to fetch from (default: main) - FMSGID_REF fmsgid branch to fetch from (default: main) + FMSGD_REF fmsgd branch to fetch from (default: main) + FMSGID_REF fmsgid branch to fetch from (default: main) + FMSG_WEBAPI_REF fmsg-webapi branch to fetch from (default: main) Options: --check report drift without modifying files; exits non-zero on drift @@ -45,6 +46,7 @@ fi FMSGD_REF="${FMSGD_REF:-main}" FMSGID_REF="${FMSGID_REF:-main}" +FMSG_WEBAPI_REF="${FMSG_WEBAPI_REF:-main}" TMP_DIR="$(mktemp -d)" cleanup() { @@ -84,9 +86,10 @@ STATUS=0 write_dd "fmsgd" "$FMSGD_REF" "fmsgd" "$REPO_ROOT/docker/postgres/init/002-fmsgd-dd.sql" || STATUS=1 write_dd "fmsgid" "$FMSGID_REF" "fmsgid" "$REPO_ROOT/docker/postgres/init/002-fmsgid-dd.sql" || STATUS=1 +write_dd "fmsg-webapi" "$FMSG_WEBAPI_REF" "fmsgd" "$REPO_ROOT/docker/postgres/init/003-fmsg-webapi-dd.sql" || STATUS=1 if [ "$MODE" = "check" ] && [ "$STATUS" -ne 0 ]; then echo "DD scripts are out of date. Run scripts/update-dd.sh with matching refs." >&2 fi -exit "$STATUS" \ No newline at end of file +exit "$STATUS" diff --git a/test/run-tests.sh b/test/run-tests.sh index d8c1c4b..a2d2bfb 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -114,8 +114,7 @@ export FMSGID_WRITER_PGPASSWORD=testfmsgidwriter export FMSGID_READER_PGPASSWORD=testfmsgidreader export FMSG_SKIP_DOMAIN_IP_CHECK=true export FMSG_SKIP_AUTHORISED_IPS=true -export FMSG_API_JWT_SECRET=test-jwt-secret -export FMSG_JWT_SECRET=test-jwt-secret +export FMSG_API_TOKEN_ED25519_PRIVATE_KEY="${FMSG_API_TOKEN_ED25519_PRIVATE_KEY:-$(openssl rand -base64 32)}" export FMSG_TLS_INSECURE_SKIP_VERIFY=true # ── Pass through ref overrides for Docker build args ───────── @@ -124,6 +123,62 @@ export FMSGID_REF=${FMSGID_REF:-main} export FMSG_WEBAPI_REF=${FMSG_WEBAPI_REF:-main} FMSG_CLI_REF=${FMSG_CLI_REF:-main} +export ALICE_ADDR="@alice@hairpin.local" +export BOB_ADDR="@bob@example.com" +export CAROL_ADDR="@carol@example.com" + +mask_secret() { + if [ -n "${GITHUB_ACTIONS:-}" ] && [ -n "$1" ]; then + echo "::add-mask::$1" + fi +} + +create_or_rotate_api_key() { + local container="$1" + local owner="$2" + local addr="$3" + local output + local api_key + + if ! output=$(docker exec "$container" /opt/fmsg-webapi/fmsg-webapi api-key create-delegation \ + -owner "$owner" \ + -agent test \ + -addr "$addr" \ + -cidr "0.0.0.0/0,::/0" \ + -expires "2099-01-01T00:00:00Z" 2>&1); then + if ! output=$(docker exec "$container" /opt/fmsg-webapi/fmsg-webapi api-key rotate-delegation \ + -owner "$owner" \ + -agent test \ + -cidr "0.0.0.0/0,::/0" \ + -expires "2099-01-01T00:00:00Z" 2>&1); then + echo "Failed to create API key for $addr" >&2 + echo "$output" | sed 's/^api_key=.*/api_key=/' >&2 + exit 1 + fi + fi + + api_key=$(echo "$output" | sed -n 's/^api_key=//p' | head -1) + if [ -z "$api_key" ]; then + echo "Failed to create API key for $addr" >&2 + echo "$output" | sed 's/^api_key=.*/api_key=/' >&2 + exit 1 + fi + + echo "$api_key" +} + +setup_api_keys() { + echo "==> Creating integration-test API keys..." + ALICE_API_KEY="$(create_or_rotate_api_key hairpin-fmsg-webapi-1 "$ALICE_ADDR" "$ALICE_ADDR")" + BOB_API_KEY="$(create_or_rotate_api_key example-fmsg-webapi-1 "$BOB_ADDR" "$BOB_ADDR")" + CAROL_API_KEY="$(create_or_rotate_api_key example-fmsg-webapi-1 "$CAROL_ADDR" "$CAROL_ADDR")" + export ALICE_API_KEY BOB_API_KEY CAROL_API_KEY + mask_secret "$ALICE_API_KEY" + mask_secret "$BOB_API_KEY" + mask_secret "$CAROL_API_KEY" + echo " ready" +} + # ── Ensure Go is on PATH ────────────────────────────────────── if ! command -v go &>/dev/null && [ -x /usr/local/go/bin/go ]; then export PATH="/usr/local/go/bin:$PATH" @@ -246,6 +301,8 @@ fi export HAIRPIN_API_URL=http://localhost:8181 export EXAMPLE_API_URL=http://localhost:8182 +setup_api_keys + # ── Run test scripts ───────────────────────────────────────── TESTS_DIR="$SCRIPT_DIR/tests" PASSED=0 diff --git a/test/seed-example.sql b/test/seed-example.sql index bff4cfc..2fa3d36 100644 --- a/test/seed-example.sql +++ b/test/seed-example.sql @@ -1,7 +1,13 @@ \connect fmsgid INSERT INTO address (address_lower, address, display_name) -VALUES ('@bob@example.com', '@Bob@example.com', 'Bob'); +VALUES ('@bob@example.com', '@Bob@example.com', 'Bob') +ON CONFLICT (address_lower) DO UPDATE +SET address = EXCLUDED.address, + display_name = EXCLUDED.display_name; INSERT INTO address (address_lower, address, display_name) -VALUES ('@carol@example.com', '@Carol@example.com', 'Carol'); +VALUES ('@carol@example.com', '@Carol@example.com', 'Carol') +ON CONFLICT (address_lower) DO UPDATE +SET address = EXCLUDED.address, + display_name = EXCLUDED.display_name; diff --git a/test/seed-hairpin.sql b/test/seed-hairpin.sql index f3c5f7b..8244f8c 100644 --- a/test/seed-hairpin.sql +++ b/test/seed-hairpin.sql @@ -1,4 +1,7 @@ -\connect fmsgid - -INSERT INTO address (address_lower, address, display_name) -VALUES ('@alice@hairpin.local', '@ALICE@hairpin.local', 'Alice'); +\connect fmsgid + +INSERT INTO address (address_lower, address, display_name) +VALUES ('@alice@hairpin.local', '@ALICE@hairpin.local', 'Alice') +ON CONFLICT (address_lower) DO UPDATE +SET address = EXCLUDED.address, + display_name = EXCLUDED.display_name; diff --git a/test/test-lib.sh b/test/test-lib.sh index 9256c03..4a36824 100644 --- a/test/test-lib.sh +++ b/test/test-lib.sh @@ -9,11 +9,21 @@ extract_send_id() { echo "$1" | sed -n 's/^ID: \([0-9][0-9]*\)$/\1/p' | head -1 } +fmsg_as() { + local api_url="$1" + local api_key="$2" + shift 2 + + FMSG_API_URL="$api_url" FMSG_API_KEY="$api_key" fmsg "$@" +} + get_max_message_id() { + local api_url="$1" + local api_key="$2" local list_output local ids - list_output=$(fmsg list 2>/dev/null || true) + list_output=$(fmsg_as "$api_url" "$api_key" list --limit 20 2>/dev/null || true) ids=$(echo "$list_output" | sed -n 's/^ID: \([0-9][0-9]*\).*/\1/p') if [ -z "$ids" ]; then @@ -25,13 +35,15 @@ get_max_message_id() { } wait_for_new_message_id() { - local since_id="$1" - local timeout="${2:-15}" + local api_url="$1" + local api_key="$2" + local since_id="$3" + local timeout="${4:-15}" local attempt local latest_id for attempt in $(seq 1 "$timeout"); do - latest_id=$(get_max_message_id) + latest_id=$(get_max_message_id "$api_url" "$api_key") if [ -n "$latest_id" ] && [ "$latest_id" -gt "$since_id" ]; then echo "$latest_id" return @@ -43,8 +55,10 @@ wait_for_new_message_id() { } wait_for_message_id_by_data() { - local expected_data="$1" - local timeout="${2:-15}" + local api_url="$1" + local api_key="$2" + local expected_data="$3" + local timeout="${4:-15}" local tmp_file local attempt local ids @@ -54,11 +68,11 @@ wait_for_message_id_by_data() { tmp_file=$(mktemp) for attempt in $(seq 1 "$timeout"); do - ids=$(fmsg list --limit 1 2>/dev/null | sed -n 's/^ID: \([0-9][0-9]*\).*/\1/p') + ids=$(fmsg_as "$api_url" "$api_key" list --limit 20 2>/dev/null | sed -n 's/^ID: \([0-9][0-9]*\).*/\1/p') ids_reversed=$(echo "$ids" | awk 'NF{a[++n]=$0} END{for(i=n;i>=1;i--) print a[i]}') for id in $ids_reversed; do - if fmsg get-data "$id" "$tmp_file" >/dev/null 2>&1 && grep -Fxq "$expected_data" "$tmp_file"; then + if fmsg_as "$api_url" "$api_key" get-data "$id" "$tmp_file" >/dev/null 2>&1 && grep -Fxq "$expected_data" "$tmp_file"; then rm -f "$tmp_file" echo "$id" return @@ -71,25 +85,3 @@ wait_for_message_id_by_data() { rm -f "$tmp_file" fail_test "timed out waiting for expected message data" } - -get_auth_token() { - local auth_file - local token - - # Prefer explicit XDG path, then Windows APPDATA, then default ~/.config. - if [ -n "${XDG_CONFIG_HOME:-}" ] && [ -f "$XDG_CONFIG_HOME/fmsg/auth.json" ]; then - auth_file="$XDG_CONFIG_HOME/fmsg/auth.json" - elif [ -n "${APPDATA:-}" ] && [ -f "$APPDATA/fmsg/auth.json" ]; then - auth_file="$APPDATA/fmsg/auth.json" - else - auth_file="$HOME/.config/fmsg/auth.json" - fi - - token=$(sed -n 's/^[[:space:]]*"token":[[:space:]]*"\([^"]*\)".*/\1/p' "$auth_file" | head -1) - - if [ -z "$token" ]; then - fail_test "could not determine auth token from $auth_file" - fi - - echo "$token" -} \ No newline at end of file diff --git a/test/tests/001-send-message.sh b/test/tests/001-send-message.sh index 75ec448..a3630bb 100644 --- a/test/tests/001-send-message.sh +++ b/test/tests/001-send-message.sh @@ -15,20 +15,16 @@ cleanup() { } trap cleanup EXIT -echo " Sending message: @alice@hairpin.local → @bob@example.com" -export FMSG_API_URL="$HAIRPIN_API_URL" -fmsg login '@alice@hairpin.local' -SEND_OUTPUT=$(fmsg send '@bob@example.com' "$MESSAGE_TEXT") +echo " Sending message: $ALICE_ADDR -> $BOB_ADDR" +SEND_OUTPUT=$(fmsg_as "$HAIRPIN_API_URL" "$ALICE_API_KEY" send "$BOB_ADDR" "$MESSAGE_TEXT") echo "$SEND_OUTPUT" echo " Waiting for cross-instance delivery..." -export FMSG_API_URL="$EXAMPLE_API_URL" -fmsg login '@bob@example.com' -MSG_ID=$(wait_for_message_id_by_data "$MESSAGE_TEXT") +MSG_ID=$(wait_for_message_id_by_data "$EXAMPLE_API_URL" "$BOB_API_KEY" "$MESSAGE_TEXT") echo " Using received message ID: $MSG_ID" -echo " Downloading message data as @bob@example.com" -fmsg get-data "$MSG_ID" "$TMP_DIR/message.txt" +echo " Downloading message data as $BOB_ADDR" +fmsg_as "$EXAMPLE_API_URL" "$BOB_API_KEY" get-data "$MSG_ID" "$TMP_DIR/message.txt" echo " Verifying downloaded message data" if ! grep -Fxq "$MESSAGE_TEXT" "$TMP_DIR/message.txt"; then diff --git a/test/tests/002-reply-message.sh b/test/tests/002-reply-message.sh index dec557b..b091e7b 100644 --- a/test/tests/002-reply-message.sh +++ b/test/tests/002-reply-message.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Test: Reply from @bob@example.com back to @alice@hairpin.local +# Test: Reply from Bob back to Alice # using the parent message ID (pid 1 on a clean database). set -euo pipefail @@ -10,24 +10,20 @@ source "$SCRIPT_DIR/../test-lib.sh" TEST_TOKEN="$(date +%s)-$$" MESSAGE_TEXT="Hey there Alice, got your message! [$TEST_TOKEN]" -echo " Sending reply: @bob@example.com → @alice@hairpin.local (pid 1)" -export FMSG_API_URL="$EXAMPLE_API_URL" -fmsg login '@bob@example.com' -SEND_OUTPUT=$(fmsg send --pid 1 '@alice@hairpin.local' "$MESSAGE_TEXT") +echo " Sending reply: $BOB_ADDR -> $ALICE_ADDR (pid 1)" +SEND_OUTPUT=$(fmsg_as "$EXAMPLE_API_URL" "$BOB_API_KEY" send --pid 1 "$ALICE_ADDR" "$MESSAGE_TEXT") echo "$SEND_OUTPUT" echo " Waiting for cross-instance delivery..." -export FMSG_API_URL="$HAIRPIN_API_URL" -fmsg login '@alice@hairpin.local' -MSG_ID=$(wait_for_message_id_by_data "$MESSAGE_TEXT") +MSG_ID=$(wait_for_message_id_by_data "$HAIRPIN_API_URL" "$ALICE_API_KEY" "$MESSAGE_TEXT") echo " Using received message ID: $MSG_ID" -echo " Reading received message as @alice@hairpin.local" -MSG_OUTPUT=$(fmsg get "$MSG_ID") +echo " Reading received message as $ALICE_ADDR" +MSG_OUTPUT=$(fmsg_as "$HAIRPIN_API_URL" "$ALICE_API_KEY" get "$MSG_ID") echo "$MSG_OUTPUT" -if ! echo "$MSG_OUTPUT" | grep -q '^From: @bob@example.com$'; then - fail_test "received message $MSG_ID was not from @bob@example.com" +if ! echo "$MSG_OUTPUT" | grep -q "^From: $BOB_ADDR$"; then + fail_test "received message $MSG_ID was not from $BOB_ADDR" fi if ! echo "$MSG_OUTPUT" | grep -q '^PID:[[:space:]]*1$'; then diff --git a/test/tests/003-reply-invalid-pid.sh b/test/tests/003-reply-invalid-pid.sh index 4b9dde5..e9ea37e 100644 --- a/test/tests/003-reply-invalid-pid.sh +++ b/test/tests/003-reply-invalid-pid.sh @@ -2,11 +2,13 @@ # Test: Sending a reply with an invalid pid (99) should fail. set -euo pipefail -echo " Sending reply with invalid pid 99: @bob@example.com → @alice@hairpin.local" -export FMSG_API_URL="$EXAMPLE_API_URL" -fmsg login '@bob@example.com' +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../test-lib.sh +source "$SCRIPT_DIR/../test-lib.sh" -if fmsg send --pid 99 '@alice@hairpin.local' "This should fail" 2>/dev/null; then +echo " Sending reply with invalid pid 99: $BOB_ADDR -> $ALICE_ADDR" + +if fmsg_as "$EXAMPLE_API_URL" "$BOB_API_KEY" send --pid 99 "$ALICE_ADDR" "This should fail" 2>/dev/null; then echo " FAIL: send with invalid pid 99 succeeded but should have failed" exit 1 fi diff --git a/test/tests/004-add-to-recipient.sh b/test/tests/004-add-to-recipient.sh index 0556974..3077dff 100644 --- a/test/tests/004-add-to-recipient.sh +++ b/test/tests/004-add-to-recipient.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Test: Send a reply-chain message from @alice@hairpin.local to @bob@example.com, -# then add @carol@example.com as a recipient using fmsg add-to, +# Test: Send a reply-chain message from Alice to Bob, +# then add Carol as a recipient using fmsg add-to, # and verify carol receives the message. set -euo pipefail @@ -12,10 +12,8 @@ TEST_TOKEN="$(date +%s)-$$" ROOT_MESSAGE_TEXT="Hello Bob, this message starts the add-to integration test. [$TEST_TOKEN]" MESSAGE_TEXT="Hello Bob and Carol, this is the add-to integration test follow-up. [$TEST_TOKEN]" -echo " Sending initial message: @alice@hairpin.local → @bob@example.com" -export FMSG_API_URL="$HAIRPIN_API_URL" -fmsg login '@alice@hairpin.local' -SEND_OUTPUT=$(fmsg send '@bob@example.com' "$ROOT_MESSAGE_TEXT") +echo " Sending initial message: $ALICE_ADDR -> $BOB_ADDR" +SEND_OUTPUT=$(fmsg_as "$HAIRPIN_API_URL" "$ALICE_API_KEY" send "$BOB_ADDR" "$ROOT_MESSAGE_TEXT") echo "$SEND_OUTPUT" echo " Getting message ID from send output" @@ -26,15 +24,11 @@ fi echo " Using root message ID: $ROOT_MSG_ID" echo " Waiting for bob to receive the original message" -export FMSG_API_URL="$EXAMPLE_API_URL" -fmsg login '@bob@example.com' -ORIGINAL_MSG_ID=$(wait_for_message_id_by_data "$ROOT_MESSAGE_TEXT") +ORIGINAL_MSG_ID=$(wait_for_message_id_by_data "$EXAMPLE_API_URL" "$BOB_API_KEY" "$ROOT_MESSAGE_TEXT") echo " Bob received original message ID: $ORIGINAL_MSG_ID" -echo " Sending follow-up with pid $ROOT_MSG_ID: @alice@hairpin.local → @bob@example.com" -export FMSG_API_URL="$HAIRPIN_API_URL" -fmsg login '@alice@hairpin.local' -FOLLOWUP_OUTPUT=$(fmsg send --pid "$ROOT_MSG_ID" '@bob@example.com' "$MESSAGE_TEXT") +echo " Sending follow-up with pid $ROOT_MSG_ID: $ALICE_ADDR -> $BOB_ADDR" +FOLLOWUP_OUTPUT=$(fmsg_as "$HAIRPIN_API_URL" "$ALICE_API_KEY" send --pid "$ROOT_MSG_ID" "$BOB_ADDR" "$MESSAGE_TEXT") echo "$FOLLOWUP_OUTPUT" echo " Getting follow-up message ID from send output" @@ -45,26 +39,20 @@ fi echo " Using follow-up message ID: $FOLLOWUP_MSG_ID" echo " Waiting for bob to receive the follow-up message" -export FMSG_API_URL="$EXAMPLE_API_URL" -fmsg login '@bob@example.com' -BOB_FOLLOWUP_MSG_ID=$(wait_for_message_id_by_data "$MESSAGE_TEXT") +BOB_FOLLOWUP_MSG_ID=$(wait_for_message_id_by_data "$EXAMPLE_API_URL" "$BOB_API_KEY" "$MESSAGE_TEXT") echo " Bob received follow-up message ID: $BOB_FOLLOWUP_MSG_ID" -echo " Adding @carol@example.com as recipient via add-to $FOLLOWUP_MSG_ID" -export FMSG_API_URL="$HAIRPIN_API_URL" -fmsg login '@alice@hairpin.local' -fmsg add-to "$FOLLOWUP_MSG_ID" '@carol@example.com' +echo " Adding $CAROL_ADDR as recipient via add-to $FOLLOWUP_MSG_ID" +fmsg_as "$HAIRPIN_API_URL" "$ALICE_API_KEY" add-to "$FOLLOWUP_MSG_ID" "$CAROL_ADDR" echo " Waiting for cross-instance delivery..." -export FMSG_API_URL="$EXAMPLE_API_URL" -fmsg login '@carol@example.com' -RECEIVED_MSG_ID=$(wait_for_message_id_by_data "$MESSAGE_TEXT") +RECEIVED_MSG_ID=$(wait_for_message_id_by_data "$EXAMPLE_API_URL" "$CAROL_API_KEY" "$MESSAGE_TEXT") echo " Using received message ID: $RECEIVED_MSG_ID" -echo " Reading received message as @carol@example.com" -MSG_OUTPUT=$(fmsg get "$RECEIVED_MSG_ID") +echo " Reading received message as $CAROL_ADDR" +MSG_OUTPUT=$(fmsg_as "$EXAMPLE_API_URL" "$CAROL_API_KEY" get "$RECEIVED_MSG_ID") echo "$MSG_OUTPUT" -if ! echo "$MSG_OUTPUT" | grep -q '^From: @alice@hairpin.local$'; then - fail_test "received message $RECEIVED_MSG_ID was not from @alice@hairpin.local" +if ! echo "$MSG_OUTPUT" | grep -q "^From: $ALICE_ADDR$"; then + fail_test "received message $RECEIVED_MSG_ID was not from $ALICE_ADDR" fi diff --git a/test/tests/005-attachments.sh b/test/tests/005-attachments.sh index a5ec8d5..e05bf3c 100755 --- a/test/tests/005-attachments.sh +++ b/test/tests/005-attachments.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Test: Send a message with 3 attachments from @alice@hairpin.local to @bob@example.com +# Test: Send a message with 3 attachments from Alice to Bob # and verify attachments can be downloaded by the recipient. set -euo pipefail @@ -20,48 +20,34 @@ echo "Attachment 1 content" > "$TMP_DIR/attachment1.txt" echo "Attachment 2 content" > "$TMP_DIR/attachment2.txt" echo "Attachment 3 content" > "$TMP_DIR/attachment3.txt" -echo " Creating draft message with 3 attachments: @alice@hairpin.local → @bob@example.com" -export FMSG_API_URL="$HAIRPIN_API_URL" -fmsg login '@alice@hairpin.local' -DRAFT_PAYLOAD=$(printf '{"from":"@alice@hairpin.local","to":["@bob@example.com"],"version":1,"type":"text/plain","size":%d,"data":"%s"}' "${#MESSAGE_TEXT}" "$MESSAGE_TEXT") -AUTH_TOKEN=$(get_auth_token) -CREATE_OUTPUT=$(curl -fsS \ - -X POST \ - -H "Authorization: Bearer $AUTH_TOKEN" \ - -H 'Content-Type: application/json' \ - --data "$DRAFT_PAYLOAD" \ - "$FMSG_API_URL/fmsg") -echo " Draft created: $CREATE_OUTPUT" +echo " Creating draft message with 3 attachments: $ALICE_ADDR -> $BOB_ADDR" +CREATE_OUTPUT=$(fmsg_as "$HAIRPIN_API_URL" "$ALICE_API_KEY" draft create "$BOB_ADDR" "$MESSAGE_TEXT") +echo "$CREATE_OUTPUT" -echo " Getting draft message ID from API output" -DRAFT_ID=$(echo "$CREATE_OUTPUT" | sed -n 's/.*"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -1) +echo " Getting draft message ID from CLI output" +DRAFT_ID=$(extract_send_id "$CREATE_OUTPUT") if [ -z "$DRAFT_ID" ]; then - fail_test "could not determine draft message ID from API output" + fail_test "could not determine draft message ID from CLI output" fi echo " Using draft message ID: $DRAFT_ID" echo " Attaching files to draft message $DRAFT_ID" -fmsg attach "$DRAFT_ID" "$TMP_DIR/attachment1.txt" -fmsg attach "$DRAFT_ID" "$TMP_DIR/attachment2.txt" -fmsg attach "$DRAFT_ID" "$TMP_DIR/attachment3.txt" +fmsg_as "$HAIRPIN_API_URL" "$ALICE_API_KEY" attach "$DRAFT_ID" "$TMP_DIR/attachment1.txt" +fmsg_as "$HAIRPIN_API_URL" "$ALICE_API_KEY" attach "$DRAFT_ID" "$TMP_DIR/attachment2.txt" +fmsg_as "$HAIRPIN_API_URL" "$ALICE_API_KEY" attach "$DRAFT_ID" "$TMP_DIR/attachment3.txt" echo " Sending draft message $DRAFT_ID" -SEND_OUTPUT=$(curl -fsS \ - -X POST \ - -H "Authorization: Bearer $AUTH_TOKEN" \ - "$FMSG_API_URL/fmsg/$DRAFT_ID/send") -echo " Draft sent: $SEND_OUTPUT" +SEND_OUTPUT=$(fmsg_as "$HAIRPIN_API_URL" "$ALICE_API_KEY" draft send "$DRAFT_ID") +echo "$SEND_OUTPUT" echo " Waiting for cross-instance delivery..." -export FMSG_API_URL="$EXAMPLE_API_URL" -fmsg login '@bob@example.com' -RECEIVED_MSG_ID=$(wait_for_message_id_by_data "$MESSAGE_TEXT") +RECEIVED_MSG_ID=$(wait_for_message_id_by_data "$EXAMPLE_API_URL" "$BOB_API_KEY" "$MESSAGE_TEXT") echo " Using received message ID: $RECEIVED_MSG_ID" -echo " Downloading attachments as @bob@example.com" -fmsg get-attach "$RECEIVED_MSG_ID" attachment1.txt "$TMP_DIR/downloaded-attachment1.txt" -fmsg get-attach "$RECEIVED_MSG_ID" attachment2.txt "$TMP_DIR/downloaded-attachment2.txt" -fmsg get-attach "$RECEIVED_MSG_ID" attachment3.txt "$TMP_DIR/downloaded-attachment3.txt" +echo " Downloading attachments as $BOB_ADDR" +fmsg_as "$EXAMPLE_API_URL" "$BOB_API_KEY" get-attach "$RECEIVED_MSG_ID" attachment1.txt "$TMP_DIR/downloaded-attachment1.txt" +fmsg_as "$EXAMPLE_API_URL" "$BOB_API_KEY" get-attach "$RECEIVED_MSG_ID" attachment2.txt "$TMP_DIR/downloaded-attachment2.txt" +fmsg_as "$EXAMPLE_API_URL" "$BOB_API_KEY" get-attach "$RECEIVED_MSG_ID" attachment3.txt "$TMP_DIR/downloaded-attachment3.txt" echo " Verifying downloaded attachment contents" cmp -s "$TMP_DIR/attachment1.txt" "$TMP_DIR/downloaded-attachment1.txt" || { diff --git a/test/tests/006-compression.sh b/test/tests/006-compression.sh index 2cadd9f..8a62219 100644 --- a/test/tests/006-compression.sh +++ b/test/tests/006-compression.sh @@ -29,34 +29,24 @@ build_large_text() { } send_draft_with_attachment() { - local message_text="$1" - local attachment_path="$2" - local draft_payload - local auth_token + local api_url="$1" + local api_key="$2" + local recipient="$3" + local message_text="$4" + local attachment_path="$5" local create_output local draft_id - draft_payload=$(printf '{"from":"@alice@hairpin.local","to":["@bob@example.com"],"version":1,"type":"text/plain","size":%d,"data":"%s"}' "${#message_text}" "$message_text") - auth_token=$(get_auth_token) + create_output=$(fmsg_as "$api_url" "$api_key" draft create "$recipient" "$message_text") - create_output=$(curl -fsS \ - -X POST \ - -H "Authorization: Bearer $auth_token" \ - -H 'Content-Type: application/json' \ - --data "$draft_payload" \ - "$FMSG_API_URL/fmsg") - - draft_id=$(echo "$create_output" | sed -n 's/.*"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -1) + draft_id=$(extract_send_id "$create_output") if [ -z "$draft_id" ]; then - fail_test "could not determine draft message ID from API output" + fail_test "could not determine draft message ID from CLI output" fi - fmsg attach "$draft_id" "$attachment_path" + fmsg_as "$api_url" "$api_key" attach "$draft_id" "$attachment_path" - curl -fsS \ - -X POST \ - -H "Authorization: Bearer $auth_token" \ - "$FMSG_API_URL/fmsg/$draft_id/send" >/dev/null + fmsg_as "$api_url" "$api_key" draft send "$draft_id" >/dev/null } MSG_NO_ATTACH=$(build_large_text "body-only-$TEST_TOKEN") @@ -78,42 +68,34 @@ if [ "$(wc -c < "$ATTACH_FILE")" -le 512 ]; then fail_test "test setup error: expected attachment payload to exceed 512 bytes" fi -echo " Sending large message without attachment: @alice@hairpin.local -> @bob@example.com" -export FMSG_API_URL="$HAIRPIN_API_URL" -fmsg login '@alice@hairpin.local' -fmsg send '@bob@example.com' "$MSG_NO_ATTACH" >/dev/null +echo " Sending large message without attachment: $ALICE_ADDR -> $BOB_ADDR" +fmsg_as "$HAIRPIN_API_URL" "$ALICE_API_KEY" send "$BOB_ADDR" "$MSG_NO_ATTACH" >/dev/null echo " Waiting for delivery of large message without attachment" -export FMSG_API_URL="$EXAMPLE_API_URL" -fmsg login '@bob@example.com' -MSG_NO_ATTACH_ID=$(wait_for_message_id_by_data "$MSG_NO_ATTACH") +MSG_NO_ATTACH_ID=$(wait_for_message_id_by_data "$EXAMPLE_API_URL" "$BOB_API_KEY" "$MSG_NO_ATTACH") echo " Received message ID (no attachment): $MSG_NO_ATTACH_ID" -fmsg get-data "$MSG_NO_ATTACH_ID" "$TMP_DIR/no-attach-data.txt" +fmsg_as "$EXAMPLE_API_URL" "$BOB_API_KEY" get-data "$MSG_NO_ATTACH_ID" "$TMP_DIR/no-attach-data.txt" if ! grep -Fxq "$MSG_NO_ATTACH" "$TMP_DIR/no-attach-data.txt"; then fail_test "downloaded body for no-attachment message did not match sent content" fi -echo " Sending large message with large attachment: @alice@hairpin.local -> @bob@example.com" -export FMSG_API_URL="$HAIRPIN_API_URL" -fmsg login '@alice@hairpin.local' -send_draft_with_attachment "$MSG_WITH_ATTACH" "$ATTACH_FILE" +echo " Sending large message with large attachment: $ALICE_ADDR -> $BOB_ADDR" +send_draft_with_attachment "$HAIRPIN_API_URL" "$ALICE_API_KEY" "$BOB_ADDR" "$MSG_WITH_ATTACH" "$ATTACH_FILE" echo " Waiting for delivery of large message with attachment" -export FMSG_API_URL="$EXAMPLE_API_URL" -fmsg login '@bob@example.com' -MSG_WITH_ATTACH_ID=$(wait_for_message_id_by_data "$MSG_WITH_ATTACH") +MSG_WITH_ATTACH_ID=$(wait_for_message_id_by_data "$EXAMPLE_API_URL" "$BOB_API_KEY" "$MSG_WITH_ATTACH") echo " Received message ID (with attachment): $MSG_WITH_ATTACH_ID" -MSG_WITH_ATTACH_OUTPUT=$(fmsg get "$MSG_WITH_ATTACH_ID") +MSG_WITH_ATTACH_OUTPUT=$(fmsg_as "$EXAMPLE_API_URL" "$BOB_API_KEY" get "$MSG_WITH_ATTACH_ID") if ! echo "$MSG_WITH_ATTACH_OUTPUT" | grep -q 'compression-attachment.txt'; then fail_test "attachment filename was not present in received message output" fi -fmsg get-data "$MSG_WITH_ATTACH_ID" "$TMP_DIR/with-attach-data.txt" +fmsg_as "$EXAMPLE_API_URL" "$BOB_API_KEY" get-data "$MSG_WITH_ATTACH_ID" "$TMP_DIR/with-attach-data.txt" if ! grep -Fxq "$MSG_WITH_ATTACH" "$TMP_DIR/with-attach-data.txt"; then fail_test "downloaded body for attachment message did not match sent content" fi -fmsg get-attach "$MSG_WITH_ATTACH_ID" compression-attachment.txt "$ATTACH_DOWNLOADED" +fmsg_as "$EXAMPLE_API_URL" "$BOB_API_KEY" get-attach "$MSG_WITH_ATTACH_ID" compression-attachment.txt "$ATTACH_DOWNLOADED" cmp -s "$ATTACH_FILE" "$ATTACH_DOWNLOADED" || fail_test "downloaded attachment content did not match sent content"