Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ on:
branches: [ "main" ]
# Publish semver tags as releases.
tags: [ 'v*.*.*' ]
pull_request:
branches: [ "main" ]

env:
# Use docker.io for Docker Hub if empty
Expand Down
44 changes: 2 additions & 42 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ env:

jobs:
test:
name: Unit Tests
name: Unit and Integration Tests
runs-on: ubuntu-latest

steps:
Expand Down Expand Up @@ -62,47 +62,7 @@ jobs:
- name: Run unit tests
run: cargo test --verbose

integration:
name: Integration Tests
runs-on: ubuntu-latest
needs: test

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Install Rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true

- name: Cache cargo registry
uses: actions/cache@v3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-

- name: Cache cargo index
uses: actions/cache@v3
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-git-

- name: Cache cargo build
uses: actions/cache@v3
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-build-target-

- name: Build release binary
- name: Build release binary for integration tests
run: cargo build --release --verbose

- name: Run integration tests
Expand Down
71 changes: 0 additions & 71 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,74 +557,3 @@ async fn read_response_with_limit(
// Convert bytes to String
String::from_utf8(accumulated).map_err(|_| OgpError::Parse)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_truncate_utf8_safe_ascii() {
let text = "Hello, World!";
assert_eq!(truncate_utf8_safe(text, 5), "Hello");
assert_eq!(truncate_utf8_safe(text, 100), text);
}

#[test]
fn test_truncate_utf8_safe_multibyte() {
// Japanese characters (3 bytes each in UTF-8)
let text = "こんにちは世界"; // "Hello World" in Japanese

// Should truncate at character boundary, not mid-character
let result = truncate_utf8_safe(text, 10);
assert!(result.is_char_boundary(result.len()));
assert!(result.len() <= 10);
}

#[test]
fn test_truncate_utf8_safe_emoji() {
// Emoji (4 bytes each in UTF-8)
let text = "Hello 🔥🚀💻 World";

// Should never panic, even if limit falls in middle of emoji
let result = truncate_utf8_safe(text, 8);
assert!(result.is_char_boundary(result.len()));
assert!(result.len() <= 8);
}

#[test]
fn test_truncate_utf8_safe_boundary_at_multibyte() {
// Create text where byte limit would fall in middle of multi-byte char
let text = "ABC日本語"; // "ABC" + Japanese (each Japanese char = 3 bytes)

// Limit of 5 would fall in middle of first Japanese character (at byte 3+2=5)
// Should truncate to "ABC" (3 bytes)
let result = truncate_utf8_safe(text, 5);
assert_eq!(result, "ABC");
assert!(result.is_char_boundary(result.len()));
}

#[test]
fn test_truncate_utf8_safe_exact_boundary() {
let text = "ABC日"; // "ABC" (3 bytes) + "日" (3 bytes) = 6 bytes total

// Limit of 6 should return full string
let result = truncate_utf8_safe(text, 6);
assert_eq!(result, text);

// Limit of 3 should return "ABC"
let result = truncate_utf8_safe(text, 3);
assert_eq!(result, "ABC");
}

#[test]
fn test_truncate_utf8_safe_empty() {
let text = "";
assert_eq!(truncate_utf8_safe(text, 10), "");
}

#[test]
fn test_truncate_utf8_safe_zero_limit() {
let text = "Hello";
assert_eq!(truncate_utf8_safe(text, 0), "");
}
}
157 changes: 157 additions & 0 deletions tests/integration_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
const TEST_SERVER_URL: &str = "http://localhost:3000";

#[tokio::test]
async fn test_health_endpoint() {
// Note: This test assumes the server is running
// In a real CI environment, we'd start the server in setup

let client = reqwest::Client::new();
let response = client
.get(format!(
"{}/api/ogp?url=https://example.com/",
TEST_SERVER_URL
))
.send()
.await;

match response {
Ok(resp) => {
assert!(resp.status().is_success() || resp.status().as_u16() == 429);
}
Err(_) => {
// Server might not be running in test environment
println!("Server not running - skipping test");
}
}
}

#[tokio::test]
async fn test_ssrf_protection() {
let client = reqwest::Client::new();

// Test blocking of localhost
let response = client
.get(format!("{}/api/ogp?url=http://127.0.0.1/", TEST_SERVER_URL))
.send()
.await;

if let Ok(resp) = response {
let status = resp.status().as_u16();
// Accept either 400 (SSRF blocked) or 429 (rate limited)
assert!(
status == 400 || status == 429,
"Expected 400 or 429, got {}",
status
);
if status == 400 {
let body = resp.text().await.unwrap();
assert!(body.contains("blocked: private IP"));
}
}
}

#[tokio::test]
async fn test_rate_limiting() {
let client = reqwest::Client::new();

// Make burst + 1 requests
let mut responses = Vec::new();
for _ in 0..11 {
let response = client
.get(format!(
"{}/api/ogp?url=https://example.com/",
TEST_SERVER_URL
))
.send()
.await;

if let Ok(resp) = response {
responses.push(resp.status().as_u16());
}
}

// At least one should be rate limited (429)
if !responses.is_empty() {
let rate_limited_count = responses.iter().filter(|&&s| s == 429).count();
println!(
"Rate limited responses: {}/{}",
rate_limited_count,
responses.len()
);
}
}

#[tokio::test]
async fn test_invalid_url() {
let client = reqwest::Client::new();

let response = client
.get(format!("{}/api/ogp?url=not-a-valid-url", TEST_SERVER_URL))
.send()
.await;

if let Ok(resp) = response {
let status = resp.status().as_u16();
assert!(
status == 400 || status == 429,
"Expected 400 or 429, got {}",
status
);
if status == 400 {
let body = resp.text().await.unwrap();
assert!(body.contains("invalid url"));
}
}
}

#[tokio::test]
async fn test_unsupported_content_type() {
let client = reqwest::Client::new();

// Try to fetch an image (should return 415)
let response = client
.get(format!(
"{}/api/ogp?url=https://httpbin.org/image/png",
TEST_SERVER_URL
))
.send()
.await;

if let Ok(resp) = response {
let status = resp.status().as_u16();
// Accept 415 (unsupported content type) or 429 (rate limited)
assert!(
status == 415 || status == 429,
"Expected 415 or 429, got {}",
status
);
if status == 415 {
let body = resp.text().await.unwrap();
assert!(body.contains("unsupported content type"));
}
}
}

#[tokio::test]
async fn test_successful_ogp_fetch() {
let client = reqwest::Client::new();

let response = client
.get(format!(
"{}/api/ogp?url=https://example.com/",
TEST_SERVER_URL
))
.send()
.await;

if let Ok(resp) = response {
if resp.status().is_success() {
let json: serde_json::Value = resp.json().await.unwrap();
assert!(json.get("url").is_some());
assert!(json.get("data").is_some());

let data = json.get("data").unwrap();
assert!(data.is_object());
}
}
}
56 changes: 56 additions & 0 deletions tests/run_integration_tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/bin/bash
set -e

echo "=== Integration Test Runner ==="
echo ""

# Build the release binary
echo "Building release binary..."
cargo build --release

# Stop any existing server
echo "Stopping existing server..."
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
sleep 1

# Start the test server
echo "Starting test server..."
RUST_LOG=warn ./target/release/nostr-proxy > /tmp/integration_test_server.log 2>&1 &
SERVER_PID=$!

# Wait for server to be ready
echo "Waiting for server to be ready..."
sleep 3

# Check if server is running
if ! ps -p $SERVER_PID > /dev/null; then
echo "❌ Server failed to start"
cat /tmp/integration_test_server.log
exit 1
fi

echo "✅ Server running (PID: $SERVER_PID)"
echo ""

# Run integration tests
echo "Running integration tests..."
cargo test --test integration_test -- --test-threads=1

TEST_EXIT_CODE=$?

# Cleanup
echo ""
echo "Stopping test server..."
kill $SERVER_PID 2>/dev/null || true
lsof -ti:3000 | xargs kill -9 2>/dev/null || true

if [ $TEST_EXIT_CODE -eq 0 ]; then
echo ""
echo "=== ✅ All integration tests passed ==="
else
echo ""
echo "=== ❌ Some integration tests failed ==="
echo "Check server logs at /tmp/integration_test_server.log"
fi

exit $TEST_EXIT_CODE
Loading
Loading