AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit. - PDS (Rust + axum + sqlx) - Auth: createAccount, createSession, refreshSession - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE) - Feed: feed.like.create, feed.repost.create - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos - Identity: resolveHandle - MST: spec-conformant (at-mst crate, 27 tests) - Repo: signed commits, TID counter (monotonic, 4096 wrap safe) - AppView (Rust + axum + sqlx) - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed) - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration) - Handle-sync worker (did:plc + did:web) - JSONB embed storage + thread columns (migration 0003) - Like/repost counter cache (migration 0004) - Tauri 2 + Svelte 5 Desktop Client - System tray (Show/Compose/Quit menu) - OS notifications (tauri-plugin-notification) - Auto-update (tauri-plugin-updater, placeholder endpoint) - Window-state (tauri-plugin-window-state) - 160-char compose with live counter - Image/Link embed rendering - LocalStorage-persisted like state - Timeline with poll (prepend new posts) - Custom TitleBar (transparent, no decorations) - Orange/IBM Plex Mono maarcade design Tests: 231 Rust + 9 vitest = 240 passed.
142 lines
5.5 KiB
Rust
142 lines
5.5 KiB
Rust
//! Integration tests for [`WebResolver`].
|
|
//!
|
|
//! These spin up a tiny mock HTTP server on `127.0.0.1:0` (just like
|
|
//! the PLC tests do). Because the DID-to-URL builder splits on `:`
|
|
//! to turn path components into URL segments, encoding `127.0.0.1:PORT`
|
|
//! in a DID doesn't produce a URL the mock can answer — so the tests
|
|
//! drive the resolver through its crate-internal `resolve_handle_at_url`
|
|
//! seam, which takes a URL directly. Production code never calls it;
|
|
//! tests do, so we can exercise the full HTTP round-trip without TLS.
|
|
//!
|
|
//! Each test runs the resolver inside a 2-second timeout so a hung
|
|
//! connection can't freeze the suite.
|
|
|
|
use at_identity::WebResolver;
|
|
use reqwest::Client;
|
|
use std::time::Duration;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpListener;
|
|
|
|
/// Spawn a single-shot mock HTTP server that always responds with
|
|
/// `status` + `body`, regardless of path. Returns the base URL.
|
|
async fn mock_server(status: u16, body: &'static [u8]) -> String {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let (mut sock, _) = match listener.accept().await {
|
|
Ok(p) => p,
|
|
Err(_) => return,
|
|
};
|
|
tokio::spawn(async move {
|
|
let mut buf = vec![0u8; 4096];
|
|
let _ = sock.read(&mut buf).await;
|
|
let reason = match status {
|
|
200 => "OK",
|
|
404 => "Not Found",
|
|
500 => "Internal Server Error",
|
|
_ => "Status",
|
|
};
|
|
let header = format!(
|
|
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
body.len()
|
|
);
|
|
let _ = sock.write_all(header.as_bytes()).await;
|
|
if !body.is_empty() {
|
|
let _ = sock.write_all(body).await;
|
|
}
|
|
let _ = sock.shutdown().await;
|
|
});
|
|
}
|
|
});
|
|
format!("http://{addr}")
|
|
}
|
|
|
|
fn resolver() -> WebResolver {
|
|
WebResolver {
|
|
client: Client::builder()
|
|
.timeout(Duration::from_secs(2))
|
|
.build()
|
|
.unwrap(),
|
|
scheme: "https".to_string(),
|
|
}
|
|
}
|
|
|
|
async fn resolve(r: &WebResolver, base: &str, path: &str) -> anyhow::Result<Option<String>> {
|
|
let url = format!("{base}{path}");
|
|
tokio::time::timeout(
|
|
Duration::from_secs(2),
|
|
r.resolve_handle_at_url(&url),
|
|
)
|
|
.await
|
|
.expect("timed out talking to mock server")
|
|
}
|
|
|
|
/// Happy path: a DID document with
|
|
/// `alsoKnownAs: ["at://alice.example.com"]` must yield
|
|
/// `Some("alice.example.com")`.
|
|
#[tokio::test]
|
|
async fn resolve_web_handle_returns_handle_from_alsoKnownAs() {
|
|
let body = br#"{"id":"did:web:example.com","alsoKnownAs":["at://alice.example.com"],"verificationMethod":[]}"#;
|
|
let base = mock_server(200, body).await;
|
|
let r = resolver();
|
|
let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap();
|
|
assert_eq!(got.as_deref(), Some("alice.example.com"));
|
|
}
|
|
|
|
/// A 404 from the remote must collapse to `Ok(None)`, never `Err`.
|
|
#[tokio::test]
|
|
async fn resolve_web_handle_handles_404() {
|
|
let base = mock_server(404, b"").await;
|
|
let r = resolver();
|
|
let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap();
|
|
assert!(got.is_none(), "404 must yield None, got {got:?}");
|
|
}
|
|
|
|
/// A 2xx with a non-JSON body (think: an HTML error page served by
|
|
/// a misconfigured reverse proxy) must also collapse to `Ok(None)`
|
|
/// so the worker doesn't see it as a transient failure.
|
|
#[tokio::test]
|
|
async fn resolve_web_handle_handles_invalid_json() {
|
|
let base = mock_server(200, b"<html>not json</html>").await;
|
|
let r = resolver();
|
|
let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap();
|
|
assert!(got.is_none(), "invalid JSON must yield None, got {got:?}");
|
|
}
|
|
|
|
/// A 2xx with valid JSON but no `alsoKnownAs` field must yield
|
|
/// `Ok(None)` (the document doesn't advertise a handle).
|
|
#[tokio::test]
|
|
async fn resolve_web_handle_handles_missing_alsoKnownAs() {
|
|
let body = br#"{"id":"did:web:example.com","verificationMethod":[]}"#;
|
|
let base = mock_server(200, body).await;
|
|
let r = resolver();
|
|
let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap();
|
|
assert!(got.is_none(), "missing alsoKnownAs must yield None");
|
|
}
|
|
|
|
/// A `alsoKnownAs` array that doesn't contain any `at://` URI must
|
|
/// yield `Ok(None)`.
|
|
#[tokio::test]
|
|
async fn resolve_web_handle_ignores_non_at_uris() {
|
|
let body = br#"{"id":"did:web:example.com","alsoKnownAs":["https://example.com","mailto:foo"]}"#;
|
|
let base = mock_server(200, body).await;
|
|
let r = resolver();
|
|
let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap();
|
|
assert!(
|
|
got.is_none(),
|
|
"non-at:// entries must not be treated as handles, got {got:?}"
|
|
);
|
|
}
|
|
|
|
/// Multiple `alsoKnownAs` entries: the **first** `at://` wins. This
|
|
/// matches the PLC client's behavior and the way real-world PDSes
|
|
/// list their primary handle first.
|
|
#[tokio::test]
|
|
async fn resolve_web_handle_picks_first_at_uri() {
|
|
let body = br#"{"id":"did:web:example.com","alsoKnownAs":["at://first.example.com","at://second.example.com"]}"#;
|
|
let base = mock_server(200, body).await;
|
|
let r = resolver();
|
|
let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap();
|
|
assert_eq!(got.as_deref(), Some("first.example.com"));
|
|
} |