diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 597979f98..e7b7399dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,8 +211,6 @@ jobs: . desktop/src-tauri save-if: ${{ github.event_name != 'pull_request' }} - - name: Start integration services - run: docker compose up -d postgres redis typesense minio minio-init - name: Get pnpm store directory id: pnpm-cache run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" @@ -246,63 +244,8 @@ jobs: key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - name: Desktop build run: just desktop-build - - name: Wait for integration services - run: | - wait_healthy() { - local service="$1" - local container="$2" - for attempt in $(seq 1 60); do - status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") - if [ "${status}" = "healthy" ]; then - echo "${service} is healthy" - return 0 - fi - sleep 2 - done - docker logs "${container}" || true - return 1 - } - wait_healthy "Postgres" "sprout-postgres" - wait_healthy "Redis" "sprout-redis" - wait_healthy "Typesense" "sprout-typesense" - wait_healthy "MinIO" "sprout-minio" - - name: Apply database schema - run: ./bin/pgschema apply --file schema/schema.sql --auto-approve - env: - PGHOST: localhost - PGPORT: "5432" - PGUSER: sprout - PGPASSWORD: sprout_dev - PGDATABASE: sprout - - name: Build relay - run: cargo build --profile ci -p sprout-relay - name: Start relay - run: | - nohup env \ - DATABASE_URL=postgres://sprout:sprout_dev@localhost:5432/sprout \ - REDIS_URL=redis://localhost:6379 \ - TYPESENSE_URL=http://localhost:8108 \ - TYPESENSE_API_KEY=sprout_dev_key \ - RELAY_URL=ws://localhost:3000 \ - SPROUT_BIND_ADDR=0.0.0.0:3000 \ - SPROUT_REQUIRE_AUTH_TOKEN=false \ - SPROUT_RECONCILE_CHANNELS=true \ - SPROUT_GIT_PROBE_WRITERS=8 \ - ./target/ci/sprout-relay > /tmp/sprout-relay.log 2>&1 & - echo $! > /tmp/sprout-relay.pid - for attempt in $(seq 1 60); do - if ! kill -0 "$(cat /tmp/sprout-relay.pid)" 2>/dev/null; then - cat /tmp/sprout-relay.log - exit 1 - fi - status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) - if [ "${status_code}" = "200" ]; then - exit 0 - fi - sleep 1 - done - cat /tmp/sprout-relay.log - exit 1 + run: ./scripts/start-relay-for-tests.sh - name: Seed desktop e2e data run: bash scripts/setup-desktop-test-data.sh - name: Desktop relay-backed e2e @@ -324,6 +267,32 @@ jobs: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + relay-e2e: + name: Relay E2E + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + save-if: ${{ github.event_name != 'pull_request' }} + - name: Start relay + run: ./scripts/start-relay-for-tests.sh + - name: Relay E2E tests + run: RELAY_URL=ws://localhost:3000 cargo test -p sprout-test-client --test '*' -- --ignored --nocapture + - name: Upload relay logs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: relay-e2e-artifacts + path: /tmp/sprout-relay.log + if-no-files-found: ignore + web: name: Web runs-on: ubuntu-latest diff --git a/crates/sprout-test-client/tests/e2e_persona.rs b/crates/sprout-test-client/tests/e2e_persona.rs new file mode 100644 index 000000000..78cf8d490 --- /dev/null +++ b/crates/sprout-test-client/tests/e2e_persona.rs @@ -0,0 +1,451 @@ +//! End-to-end tests for kind:30175 persona events (NIP-AP). +//! +//! These tests verify the relay correctly handles persona events: +//! - Accepts valid persona events with proper d-tag slugs +//! - Enforces NIP-33 replacement semantics (same d-tag, newer timestamp wins) +//! - Rejects invalid d-tag values (empty, too long, invalid characters) +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! RELAY_URL=ws://localhost:3000 cargo test --test e2e_persona -- --ignored +//! ``` + +use std::time::Duration; + +use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp}; +use sprout_test_client::SproutTestClient; + +const PERSONA_KIND: u16 = 30175; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn sub_id(name: &str) -> String { + format!("e2e-persona-{name}-{}", uuid::Uuid::new_v4()) +} + +/// Build a minimal persona event with the given d-tag and content. +fn persona_event(keys: &Keys, d_tag: &str, content: &str) -> nostr::Event { + EventBuilder::new(Kind::Custom(PERSONA_KIND), content) + .tags(vec![Tag::parse(["d", d_tag]).unwrap()]) + .sign_with_keys(keys) + .unwrap() +} + +/// Build a persona event with an explicit created_at timestamp. +fn persona_event_at(keys: &Keys, d_tag: &str, content: &str, created_at: u64) -> nostr::Event { + EventBuilder::new(Kind::Custom(PERSONA_KIND), content) + .tags(vec![Tag::parse(["d", d_tag]).unwrap()]) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .unwrap() +} + +// ── Publish and query back ─────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_persona_publish_and_query() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = format!("test-persona-{}", &uuid::Uuid::new_v4().to_string()[..8]); + + let content = serde_json::json!({ + "name": &d_tag, + "display_name": "Test Persona", + "description": "A test persona for E2E validation" + }) + .to_string(); + + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + // Publish persona event + let event = persona_event(&keys, &d_tag, &content); + let ok = client + .send_event(event.clone()) + .await + .expect("send persona"); + assert!(ok.accepted, "relay rejected persona event: {}", ok.message); + + // Query it back using NIP-33 filter (kind + author + d-tag) + let sid = sub_id("query"); + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(keys.public_key()) + .custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]); + + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect events"); + + assert_eq!(events.len(), 1, "expected exactly one persona event"); + let ev = &events[0]; + assert_eq!(ev.content, content); + assert_eq!(ev.pubkey, keys.public_key()); + assert_eq!(ev.kind, Kind::Custom(PERSONA_KIND)); + + client.disconnect().await.expect("disconnect"); +} + +// ── NIP-33 replacement semantics ───────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_persona_nip33_replacement_newer_wins() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = format!("replace-{}", &uuid::Uuid::new_v4().to_string()[..8]); + + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + // Publish older version + let old_content = r#"{"name":"old","display_name":"Old","description":"Old version"}"#; + let old_event = persona_event_at(&keys, &d_tag, old_content, 1_700_000_000); + let ok = client.send_event(old_event).await.expect("send old"); + assert!(ok.accepted, "relay rejected old event: {}", ok.message); + + // Publish newer version with same d-tag + let new_content = r#"{"name":"new","display_name":"New","description":"New version"}"#; + let new_event = persona_event_at(&keys, &d_tag, new_content, 1_700_000_100); + let ok = client.send_event(new_event).await.expect("send new"); + assert!(ok.accepted, "relay rejected new event: {}", ok.message); + + // Query — should return only the newer event + let sid = sub_id("replace"); + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(keys.public_key()) + .custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]); + + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert_eq!(events.len(), 1, "NIP-33: only newest event should remain"); + let ev = &events[0]; + assert_eq!(ev.content, new_content, "should be the newer version"); + + client.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_persona_nip33_older_does_not_replace_newer() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = format!("no-replace-{}", &uuid::Uuid::new_v4().to_string()[..8]); + + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + // Publish newer version first + let new_content = r#"{"name":"new","display_name":"New","description":"Newer"}"#; + let new_event = persona_event_at(&keys, &d_tag, new_content, 1_700_000_200); + let ok = client.send_event(new_event).await.expect("send new"); + assert!(ok.accepted, "relay rejected new event: {}", ok.message); + + // Publish older version — relay should accept but not replace + let old_content = r#"{"name":"old","display_name":"Old","description":"Older"}"#; + let old_event = persona_event_at(&keys, &d_tag, old_content, 1_700_000_100); + let _ok = client.send_event(old_event).await.expect("send old"); + // Note: relay may accept or reject the older event depending on implementation. + // The key assertion is that querying returns the newer one. + + // Query — should still return the newer event + let sid = sub_id("no-replace"); + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(keys.public_key()) + .custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]); + + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert_eq!(events.len(), 1, "should have exactly one event"); + let ev = &events[0]; + assert_eq!(ev.content, new_content, "newer event should persist"); + + client.disconnect().await.expect("disconnect"); +} + +// ── D-tag validation ───────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_persona_rejects_empty_d_tag() { + let url = relay_url(); + let keys = Keys::generate(); + + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let event = EventBuilder::new( + Kind::Custom(PERSONA_KIND), + r#"{"name":"x","display_name":"X","description":"X"}"#, + ) + .tags(vec![Tag::parse(["d", ""]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + + let ok = client.send_event(event).await.expect("send"); + assert!(!ok.accepted, "relay should reject persona with empty d-tag"); + assert!( + ok.message.contains("empty") || ok.message.contains("d") || ok.message.contains("tag"), + "rejection message should mention d-tag issue, got: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_persona_rejects_missing_d_tag() { + let url = relay_url(); + let keys = Keys::generate(); + + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + // No d-tag at all + let event = EventBuilder::new( + Kind::Custom(PERSONA_KIND), + r#"{"name":"x","display_name":"X","description":"X"}"#, + ) + .sign_with_keys(&keys) + .unwrap(); + + let ok = client.send_event(event).await.expect("send"); + assert!(!ok.accepted, "relay should reject persona without d-tag"); + + client.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_persona_rejects_d_tag_too_long() { + let url = relay_url(); + let keys = Keys::generate(); + + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + // 65 characters — exceeds the 64-char limit + let long_slug = "a".repeat(65); + let event = persona_event( + &keys, + &long_slug, + r#"{"name":"x","display_name":"X","description":"X"}"#, + ); + let ok = client.send_event(event).await.expect("send"); + assert!( + !ok.accepted, + "relay should reject persona with d-tag > 64 chars" + ); + assert!( + ok.message.contains("long") || ok.message.contains("64"), + "rejection should mention length, got: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_persona_rejects_d_tag_uppercase() { + let url = relay_url(); + let keys = Keys::generate(); + + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let event = persona_event( + &keys, + "My-Persona", + r#"{"name":"x","display_name":"X","description":"X"}"#, + ); + let ok = client.send_event(event).await.expect("send"); + assert!( + !ok.accepted, + "relay should reject persona with uppercase d-tag" + ); + + client.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_persona_rejects_d_tag_special_chars() { + let url = relay_url(); + let keys = Keys::generate(); + + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let event = persona_event( + &keys, + "my.persona!", + r#"{"name":"x","display_name":"X","description":"X"}"#, + ); + let ok = client.send_event(event).await.expect("send"); + assert!( + !ok.accepted, + "relay should reject persona with special chars in d-tag" + ); + + client.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_persona_rejects_d_tag_starting_with_underscore() { + let url = relay_url(); + let keys = Keys::generate(); + + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + // Slug must start with [a-z0-9], not underscore + let event = persona_event( + &keys, + "_invalid", + r#"{"name":"x","display_name":"X","description":"X"}"#, + ); + let ok = client.send_event(event).await.expect("send"); + assert!( + !ok.accepted, + "relay should reject persona with d-tag starting with underscore" + ); + + client.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_persona_accepts_valid_slugs() { + let url = relay_url(); + let keys = Keys::generate(); + + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + // Various valid slug patterns + let valid_slugs = [ + "a", + "my-persona", + "persona_v2", + "0-starts-with-digit", + "a-b-c-d-e", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // exactly 64 chars + ]; + + for slug in valid_slugs { + let content = format!( + r#"{{"name":"{}","display_name":"Test","description":"Valid slug test"}}"#, + slug + ); + let event = persona_event(&keys, slug, &content); + let ok = client.send_event(event).await.expect("send"); + assert!( + ok.accepted, + "relay should accept valid slug '{}', got rejection: {}", + slug, ok.message + ); + } + + client.disconnect().await.expect("disconnect"); +} + +// ── Multiple personas per author ───────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_persona_multiple_per_author() { + let url = relay_url(); + let keys = Keys::generate(); + + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + // Publish two different personas (different d-tags) + let slug_a = format!("persona-a-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let slug_b = format!("persona-b-{}", &uuid::Uuid::new_v4().to_string()[..8]); + + let event_a = persona_event( + &keys, + &slug_a, + r#"{"name":"a","display_name":"Persona A","description":"First"}"#, + ); + let event_b = persona_event( + &keys, + &slug_b, + r#"{"name":"b","display_name":"Persona B","description":"Second"}"#, + ); + + let ok_a = client.send_event(event_a).await.expect("send A"); + assert!(ok_a.accepted, "persona A rejected: {}", ok_a.message); + + let ok_b = client.send_event(event_b).await.expect("send B"); + assert!(ok_b.accepted, "persona B rejected: {}", ok_b.message); + + // Query all personas by this author + let sid = sub_id("multi"); + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(keys.public_key()); + + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + events.len() >= 2, + "expected at least 2 persona events, got {}", + events.len() + ); + + client.disconnect().await.expect("disconnect"); +} diff --git a/scripts/start-relay-for-tests.sh b/scripts/start-relay-for-tests.sh new file mode 100755 index 000000000..76a01d7ec --- /dev/null +++ b/scripts/start-relay-for-tests.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# ============================================================================= +# start-relay-for-tests.sh — Start the Sprout relay and its backing services +# ============================================================================= +# Shared script for CI jobs that need a running relay. Starts docker compose +# services, waits for health, applies the schema, builds the relay, starts it, +# and polls readiness. +# +# Usage: +# ./scripts/start-relay-for-tests.sh [--profile ] +# +# Options: +# --profile Cargo build profile (default: ci) +# +# Exports: +# RELAY_URL=ws://localhost:3000 +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# ── Defaults ────────────────────────────────────────────────────────────────── + +CARGO_PROFILE="${CARGO_PROFILE:-ci}" + +# ── Parse args ──────────────────────────────────────────────────────────────── + +while [[ $# -gt 0 ]]; do + case "$1" in + --profile) + CARGO_PROFILE="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +# ── Colors ──────────────────────────────────────────────────────────────────── + +BLUE='\033[0;34m' +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +log() { echo -e "${BLUE}[relay-test]${NC} $*"; } +ok() { echo -e "${GREEN}[relay-test]${NC} $*"; } +err() { echo -e "${RED}[relay-test]${NC} $*" >&2; } + +# ── Start docker compose services ──────────────────────────────────────────── + +cd "${REPO_ROOT}" + +log "Starting docker compose services..." +docker compose up -d postgres redis typesense minio minio-init + +# ── Wait for services to be healthy ────────────────────────────────────────── + +wait_healthy() { + local service="$1" + local container="$2" + log "Waiting for ${service}..." + for attempt in $(seq 1 60); do + status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") + if [ "${status}" = "healthy" ]; then + ok "${service} is healthy" + return 0 + fi + sleep 2 + done + err "${service} did not become healthy within 120s" + docker logs "${container}" || true + return 1 +} + +wait_healthy "Postgres" "sprout-postgres" +wait_healthy "Redis" "sprout-redis" +wait_healthy "Typesense" "sprout-typesense" +wait_healthy "MinIO" "sprout-minio" + +# ── Apply database schema ──────────────────────────────────────────────────── + +log "Applying database schema..." +export PGHOST=localhost +export PGPORT=5432 +export PGUSER=sprout +export PGPASSWORD=sprout_dev +export PGDATABASE=sprout + +./bin/pgschema apply --file schema/schema.sql --auto-approve +ok "Schema applied" + +# ── Build relay ────────────────────────────────────────────────────────────── + +log "Building relay (profile: ${CARGO_PROFILE})..." +cargo build --profile "${CARGO_PROFILE}" -p sprout-relay +ok "Relay built" + +# ── Start relay ────────────────────────────────────────────────────────────── + +log "Starting relay..." +nohup env \ + DATABASE_URL=postgres://sprout:sprout_dev@localhost:5432/sprout \ + REDIS_URL=redis://localhost:6379 \ + TYPESENSE_URL=http://localhost:8108 \ + TYPESENSE_API_KEY=sprout_dev_key \ + RELAY_URL=ws://localhost:3000 \ + SPROUT_BIND_ADDR=0.0.0.0:3000 \ + SPROUT_REQUIRE_AUTH_TOKEN=false \ + SPROUT_RECONCILE_CHANNELS=true \ + SPROUT_GIT_PROBE_WRITERS=8 \ + "./target/${CARGO_PROFILE}/sprout-relay" > /tmp/sprout-relay.log 2>&1 & +echo $! > /tmp/sprout-relay.pid + +# ── Poll readiness ─────────────────────────────────────────────────────────── + +log "Waiting for relay readiness..." +for attempt in $(seq 1 60); do + if ! kill -0 "$(cat /tmp/sprout-relay.pid)" 2>/dev/null; then + err "Relay process died" + cat /tmp/sprout-relay.log + exit 1 + fi + status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) + if [ "${status_code}" = "200" ]; then + ok "Relay is ready at ws://localhost:3000" + export RELAY_URL=ws://localhost:3000 + exit 0 + fi + sleep 1 +done + +err "Relay did not become ready within 60s" +cat /tmp/sprout-relay.log +exit 1