diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..64f6bb0 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,6 @@ +# Copilot Instructions + +Repository-specific agent instructions are defined in [AGENTS.md](../AGENTS.md). + +- Read and follow [AGENTS.md](../AGENTS.md) before making changes. +- Use [AGENTS.md](../AGENTS.md) as the source of truth for this repository. \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..69c9698 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# AGENTS.md + +This file is the primary instruction source for coding agents working in this repository. + +## Scope + +- Follow this file before using other repo-specific agent instruction files. +- Treat this repository as a Docker-based integration-test harness for the fmsg stack. + +## Integration Test Guidance + +- The integration tests live under `test/` and exercise the system with `fmsg-cli`. +- For CLI behavior, flags, and command syntax used by the integration tests, use the fmsg-cli README as the authoritative reference: + https://github.com/markmnl/fmsg-cli/blob/main/README.md +- Do not assume `CLI_USAGE.md` in this repository is the canonical CLI reference if it conflicts with the upstream fmsg-cli README. + +## Editing Guidance + +- Keep changes minimal and targeted. +- Preserve the existing shell style in test scripts and runner scripts. +- When adding or updating integration tests, follow the existing numbering and naming pattern in `test/tests/`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2c0c992 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,6 @@ +# CLAUDE.md + +Repository-specific agent instructions are defined in [AGENTS.md](AGENTS.md). + +- Read and follow [AGENTS.md](AGENTS.md) before making changes. +- Use [AGENTS.md](AGENTS.md) as the source of truth if guidance appears in multiple places. diff --git a/CLI_USAGE.md b/CLI_USAGE.md deleted file mode 100644 index 997dff6..0000000 --- a/CLI_USAGE.md +++ /dev/null @@ -1,83 +0,0 @@ -## Usage - -### Authentication - -Before using any other command, log in: - -```sh -fmsg login -``` - -You will be prompted for your FMSG address (e.g. `@user@example.com`). A JWT token is generated locally and stored in `$XDG_CONFIG_HOME/fmsg/auth.json` (typically `~/.config/fmsg/auth.json`) with `0600` permissions. The token is valid for 24 hours. - -### Configuration - -| Variable | Default | Description | -|---------------|--------------------------|---------------------------| -| `FMSG_API_URL` | `http://localhost:8000` | Base URL of the fmsg-webapi | - -### Commands - -| Command | Description | -|---------|-------------| -| `fmsg login` | Authenticate and store a local token | -| `fmsg list [--limit N] [--offset N]` | List messages for the authenticated user | -| `fmsg get ` | Retrieve a message by ID | -| `fmsg send ` | Send a message (file path, text, or `-` for stdin) | -| `fmsg update [file\|text\|->` | Update a draft message | -| `fmsg del ` | Delete a draft message by ID | -| `fmsg add-to [recipient...]` | Add additional recipients to a message | -| `fmsg attach ` | Upload a file attachment to a message | -| `fmsg get-attach ` | Download an attachment | -| `fmsg get-data ` | Download the message body data | -| `fmsg rm-attach ` | Remove an attachment from a message | - -### Examples - -```sh -# Login -fmsg login - -# List messages -fmsg list -fmsg list --limit 10 --offset 20 - -# Get a specific message -fmsg get 101 - -# Send a message -fmsg send @recipient@example.com "Hello, world!" -fmsg send @recipient@example.com ./message.txt -echo "Hello via stdin" | fmsg send @recipient@example.com - - -# Reply to an existing message -fmsg send --pid 12345 @recipient@example.com "hey there!" - -# Send with optional flags -fmsg send --topic "Project update" --important @recipient@example.com ./update.txt -fmsg send --no-reply @recipient@example.com "Do not reply to this" - -# Add additional recipients to a message -fmsg add-to 101 @other@example.com -fmsg add-to 101 @cc1@example.com @cc2@example.com - -# Update a draft message -fmsg update 42 --topic "New topic" -fmsg update 42 --to @newrecipient@example.com "Updated body text" -fmsg update 42 --important - -# Delete a draft message -fmsg del 101 - -# Upload attachment -fmsg attach 101 ./report.pdf - -# Download attachment -fmsg get-attach 101 report.pdf ./downloaded-report.pdf - -# Download message body data -fmsg get-data 101 ./message-body.txt - -# Remove attachment -fmsg rm-attach 101 report.pdf -``` \ No newline at end of file diff --git a/docker/postgres/init/002-fmsgd-dd.sql b/docker/postgres/init/002-fmsgd-dd.sql index d25ebe3..5081af4 100644 --- a/docker/postgres/init/002-fmsgd-dd.sql +++ b/docker/postgres/init/002-fmsgd-dd.sql @@ -85,3 +85,4 @@ drop trigger if exists trg_msg_add_to_insert on msg_add_to; create trigger trg_msg_add_to_insert after insert on msg_add_to for each row execute function notify_msg_to_insert(); + diff --git a/test/run-tests.sh b/test/run-tests.sh old mode 100644 new mode 100755 diff --git a/test/test-lib.sh b/test/test-lib.sh new file mode 100644 index 0000000..bfd4140 --- /dev/null +++ b/test/test-lib.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash + +fail_test() { + echo " FAIL: $1" + exit 1 +} + +extract_send_id() { + echo "$1" | sed -n 's/^ID: \([0-9][0-9]*\)$/\1/p' | head -1 +} + +extract_wait_latest_id() { + echo "$1" | sed -n 's/^New message available\. Latest ID: \([0-9][0-9]*\)$/\1/p' | head -1 +} + +get_max_message_id() { + local list_output + local ids + + list_output=$(fmsg list 2>/dev/null || true) + ids=$(echo "$list_output" | sed -n 's/^ID: \([0-9][0-9]*\).*/\1/p') + + if [ -z "$ids" ]; then + echo 0 + return + fi + + echo "$ids" | sort -nr | head -1 +} + +wait_for_new_message_id() { + local since_id="$1" + local timeout="${2:-15}" + local wait_output + local latest_id + + wait_output=$(fmsg wait --since-id "$since_id" --timeout "$timeout") + echo " $wait_output" >&2 + + latest_id=$(extract_wait_latest_id "$wait_output") + if [ -z "$latest_id" ]; then + fail_test "timed out waiting for a new message after ID $since_id" + fi + + echo "$latest_id" +} + +wait_for_message_id_by_data() { + local expected_data="$1" + local timeout="${2:-15}" + local tmp_file + local attempt + local ids + local id + + tmp_file=$(mktemp) + + for attempt in $(seq 1 "$timeout"); do + ids=$(fmsg list --limit 20 2>/dev/null | sed -n 's/^ID: \([0-9][0-9]*\).*/\1/p') + + for id in $ids; do + if fmsg get-data "$id" "$tmp_file" >/dev/null 2>&1 && grep -Fxq "$expected_data" "$tmp_file"; then + rm -f "$tmp_file" + echo "$id" + return + fi + done + + sleep 1 + done + + rm -f "$tmp_file" + fail_test "timed out waiting for expected message data" +} + +get_auth_token() { + local auth_file + local token + + auth_file="${XDG_CONFIG_HOME:-$HOME/.config}/fmsg/auth.json" + 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 2ff8fa8..75ec448 100644 --- a/test/tests/001-send-message.sh +++ b/test/tests/001-send-message.sh @@ -1,25 +1,39 @@ #!/usr/bin/env bash # Test: Send a message from @alice@hairpin.local to @bob@example.com -# and verify it is received. +# and verify the recipient can download the message data. set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../test-lib.sh +source "$SCRIPT_DIR/../test-lib.sh" + +TMP_DIR=$(mktemp -d) +TEST_TOKEN="$(date +%s)-$$" +MESSAGE_TEXT="Hello Bob, this is an integration test. [$TEST_TOKEN]" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + echo " Sending message: @alice@hairpin.local → @bob@example.com" export FMSG_API_URL="$HAIRPIN_API_URL" -printf '@alice@hairpin.local\n' | fmsg login -sleep 3 -fmsg send '@bob@example.com' "Hello Bob, this is an integration test." +fmsg login '@alice@hairpin.local' +SEND_OUTPUT=$(fmsg send '@bob@example.com' "$MESSAGE_TEXT") +echo "$SEND_OUTPUT" echo " Waiting for cross-instance delivery..." -sleep 5 - -echo " Reading messages as @bob@example.com" export FMSG_API_URL="$EXAMPLE_API_URL" -printf '@bob@example.com\n' | fmsg login -sleep 3 -MSG_OUTPUT=$(fmsg list) -echo "$MSG_OUTPUT" +fmsg login '@bob@example.com' +MSG_ID=$(wait_for_message_id_by_data "$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" -if echo "$MSG_OUTPUT" | grep -q "No messages"; then - echo " FAIL: @bob@example.com has no messages — delivery did not succeed" +echo " Verifying downloaded message data" +if ! grep -Fxq "$MESSAGE_TEXT" "$TMP_DIR/message.txt"; then + fail_test "downloaded message data did not match expected content" + echo " Downloaded data:" + cat "$TMP_DIR/message.txt" exit 1 fi diff --git a/test/tests/002-reply-message.sh b/test/tests/002-reply-message.sh index a93d371..6188fce 100644 --- a/test/tests/002-reply-message.sh +++ b/test/tests/002-reply-message.sh @@ -3,23 +3,29 @@ # using the parent message ID (pid 1 on a clean database). set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../test-lib.sh +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" -printf '@bob@example.com\n' | fmsg login -sleep 3 -fmsg send --pid 1 '@alice@hairpin.local' "Hey there Alice, got your message!" +fmsg login '@bob@example.com' +SEND_OUTPUT=$(fmsg send --pid 1 '@alice@hairpin.local' "$MESSAGE_TEXT") +echo "$SEND_OUTPUT" echo " Waiting for cross-instance delivery..." -sleep 5 - -echo " Reading messages as @alice@hairpin.local" export FMSG_API_URL="$HAIRPIN_API_URL" -printf '@alice@hairpin.local\n' | fmsg login -sleep 3 -MSG_OUTPUT=$(fmsg list) +fmsg login '@alice@hairpin.local' +MSG_ID=$(wait_for_message_id_by_data "$MESSAGE_TEXT") +echo " Using received message ID: $MSG_ID" + +echo " Reading received message as @alice@hairpin.local" +MSG_OUTPUT=$(fmsg get "$MSG_ID") echo "$MSG_OUTPUT" -if echo "$MSG_OUTPUT" | grep -q "No messages"; then - echo " FAIL: @alice@hairpin.local has no messages — reply delivery did not succeed" - exit 1 +if ! echo "$MSG_OUTPUT" | grep -q '^From: @bob@example.com$'; then + fail_test "received message $MSG_ID was not from @bob@example.com" fi diff --git a/test/tests/003-reply-invalid-pid.sh b/test/tests/003-reply-invalid-pid.sh index 97103d0..4b9dde5 100644 --- a/test/tests/003-reply-invalid-pid.sh +++ b/test/tests/003-reply-invalid-pid.sh @@ -4,8 +4,7 @@ set -euo pipefail echo " Sending reply with invalid pid 99: @bob@example.com → @alice@hairpin.local" export FMSG_API_URL="$EXAMPLE_API_URL" -printf '@bob@example.com\n' | fmsg login -sleep 3 +fmsg login '@bob@example.com' if fmsg send --pid 99 '@alice@hairpin.local' "This should fail" 2>/dev/null; then echo " FAIL: send with invalid pid 99 succeeded but should have failed" diff --git a/test/tests/004-add-to-recipient.sh b/test/tests/004-add-to-recipient.sh index 8ad6173..306e2c0 100644 --- a/test/tests/004-add-to-recipient.sh +++ b/test/tests/004-add-to-recipient.sh @@ -4,37 +4,47 @@ # and verify carol receives the message. set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../test-lib.sh +source "$SCRIPT_DIR/../test-lib.sh" + +TEST_TOKEN="$(date +%s)-$$" +MESSAGE_TEXT="Hello Bob, this is the add-to integration test. [$TEST_TOKEN]" + echo " Sending initial message: @alice@hairpin.local → @bob@example.com" export FMSG_API_URL="$HAIRPIN_API_URL" -printf '@alice@hairpin.local\n' | fmsg login -sleep 3 -fmsg send '@bob@example.com' "Hello Bob, this is the add-to integration test." +fmsg login '@alice@hairpin.local' +SEND_OUTPUT=$(fmsg send '@bob@example.com' "$MESSAGE_TEXT") +echo "$SEND_OUTPUT" -echo " Waiting for delivery..." -sleep 3 - -echo " Getting message ID from alice's sent messages" -MSG_ID=$(fmsg list | grep -oE '\b[0-9]+\b' | head -1) +echo " Getting message ID from send output" +MSG_ID=$(extract_send_id "$SEND_OUTPUT") if [ -z "$MSG_ID" ]; then - echo " FAIL: could not determine message ID from fmsg list" - exit 1 + fail_test "could not determine message ID from fmsg send output" fi echo " Using message ID: $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 "$MESSAGE_TEXT") +echo " Bob received original message ID: $ORIGINAL_MSG_ID" + echo " Adding @carol@example.com as recipient via add-to $MSG_ID" +export FMSG_API_URL="$HAIRPIN_API_URL" +fmsg login '@alice@hairpin.local' fmsg add-to "$MSG_ID" '@carol@example.com' echo " Waiting for cross-instance delivery..." -sleep 3 - -echo " Reading messages as @carol@example.com" export FMSG_API_URL="$EXAMPLE_API_URL" -printf '@carol@example.com\n' | fmsg login -sleep 3 -MSG_OUTPUT=$(fmsg list) +fmsg login '@carol@example.com' +RECEIVED_MSG_ID=$(wait_for_message_id_by_data "$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 "$MSG_OUTPUT" -if echo "$MSG_OUTPUT" | grep -q "No messages"; then - echo " FAIL: @carol@example.com has no messages — add-to delivery did not succeed" - exit 1 +if ! echo "$MSG_OUTPUT" | grep -q '^From: @alice@hairpin.local$'; then + fail_test "received message $RECEIVED_MSG_ID was not from @alice@hairpin.local" fi diff --git a/test/tests/005-attachments.sh b/test/tests/005-attachments.sh new file mode 100755 index 0000000..a5ec8d5 --- /dev/null +++ b/test/tests/005-attachments.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Test: Send a message with 3 attachments from @alice@hairpin.local to @bob@example.com +# and verify attachments can be downloaded by the recipient. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../test-lib.sh +source "$SCRIPT_DIR/../test-lib.sh" + +TMP_DIR=$(mktemp -d) +TEST_TOKEN="$(date +%s)-$$" +MESSAGE_TEXT="Hello Bob, this message has 3 attachments! [$TEST_TOKEN]" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +echo " Creating test attachment files" +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 " 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) +if [ -z "$DRAFT_ID" ]; then + fail_test "could not determine draft message ID from API 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" + +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" + +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") +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 " Verifying downloaded attachment contents" +cmp -s "$TMP_DIR/attachment1.txt" "$TMP_DIR/downloaded-attachment1.txt" || { + echo " FAIL: downloaded attachment1.txt content did not match" + exit 1 +} +cmp -s "$TMP_DIR/attachment2.txt" "$TMP_DIR/downloaded-attachment2.txt" || { + echo " FAIL: downloaded attachment2.txt content did not match" + exit 1 +} +cmp -s "$TMP_DIR/attachment3.txt" "$TMP_DIR/downloaded-attachment3.txt" || { + echo " FAIL: downloaded attachment3.txt content did not match" + exit 1 +}