Files
maarcadetweet/crates/appview/tests/embeds_integration.rs
T
tomdeboneandClaude Opus 5 a2a371b7d9 feat(appview): Bearer-Auth für Timeline und Notifications
Die AppView hatte keinerlei Authentifizierung: jeder konnte
/api/notifications?did=<beliebig> lesen und per /seen als gelesen
markieren. Mit Phase 8 sind das die ersten privaten Daten im System.

Das Access-JWT der PDS trug von Anfang an sub, scope
"com.atproto.access" und aud "did:web:appview…" — es war für die
AppView ausgestellt, nur hat sie es nie geprüft. Neu ist deshalb vor
allem die Schlüsselbeschaffung: auth.rs holt das DID-Dokument der PDS
(PDS_INTERNAL_URL, sonst PDS_PUBLIC_URL), cached den Schlüssel und lädt
ihn bei einem Verifikationsfehler nach — höchstens einmal pro Minute,
damit Müll-Tokens kein Werkzeug werden, die PDS zu fluten. Ein
Schlüsselwechsel braucht damit keinen Neustart.

Ist die PDS beim Start weg, warnt die AppView nur und startet trotzdem
(sie indiziert den Firehose, der von der lokalen PDS unabhängig ist).
Ist der Schlüssel beim Prüfen eines Tokens nicht zu beschaffen, gibt es
503 — fail closed.

Geschützt: /api/timeline/home und die drei Notification-Endpoints, jeweils
mit sub == did. Öffentlich bleiben Profile, Suche, Posts, Threads und die
Follower-Listen; das sind in AT Proto öffentliche Records.

401 AuthMissing / 401 TokenInvalid / 403 Forbidden / 503 AuthUnavailable.
TokenInvalid ist ein Vertrag mit dem Client: daran erkennt er, dass er
sein Token erneuern und einmal wiederholen muss.

Dazu CORS: statt Any für alles jetzt eine Allowlist über
APPVIEW_CORS_ORIGINS (unset = altes Verhalten plus Warnung), und
/internal/ingest-commit liegt außerhalb der CORS-Schicht — die Route
wird server-zu-server aufgerufen, ein Allow-Origin darauf würde nur
einer Webseite helfen, in den Index zu schreiben.

APPVIEW_AUTH_REQUIRED=false stellt das alte Verhalten her (VPN-Instanz,
fail-open-Tests) und warnt beim Start in Großbuchstaben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 23:02:27 +02:00

455 lines
14 KiB
Rust

//! Integration tests for embed capture + thread hydration.
//!
//! These exercise the AppView's `embed` storage and the new
//! `/api/post/{uri}` thread-hydration endpoint end-to-end:
//!
//! - `timeline_includes_embed` — seed a post with an image embed, query
//! the home timeline, verify the embed came back as raw JSON.
//! - `timeline_includes_external_embed` — same but with a link card.
//! - `post_endpoint_returns_thread` — seed 3 posts (root + reply + reply
//! to reply), fetch the middle one's URI, verify the thread
//! hydration returns the right parent + root rows.
//!
//! Like the sibling API tests these are fail-open: if the AppView
//! service isn't running on the expected port the test prints a notice
//! and returns rather than panicking. The point of the tests is to
//! catch regressions in CI where the service IS up.
mod common;
use common::TestAuth;
use serde_json::{json, Value};
use std::time::Duration;
const APPVIEW_URL: &str = "http://127.0.0.1:2584";
async fn client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap()
}
async fn wait_for_appview_db() -> bool {
let c = client().await;
for _ in 0..20 {
if let Ok(r) = c.get(format!("{APPVIEW_URL}/healthz")).send().await {
if r.status().is_success() {
return true;
}
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
false
}
async fn db_reachable() -> bool {
let Some(url) = std::env::var("DATABASE_URL_APPVIEW").ok() else {
return false;
};
matches!(
tokio::time::timeout(Duration::from_secs(2), sqlx::PgPool::connect(&url)).await,
Ok(Ok(_))
)
}
/// `/api/timeline/home` requires a token whose `sub` is the requested
/// DID. The DIDs here are synthetic, so the token is minted from the
/// PDS signing secret — see `tests/common/mod.rs`. `None` → skip.
async fn auth_or_skip() -> Option<TestAuth> {
TestAuth::probe(&client().await, APPVIEW_URL).await
}
/// `GET /api/timeline/home` as `did`, authenticated when required.
async fn get_timeline(
c: &reqwest::Client,
auth: &TestAuth,
did: &str,
extra: &[(&str, &str)],
) -> reqwest::Response {
let mut params: Vec<(&str, &str)> = vec![("did", did)];
params.extend_from_slice(extra);
auth.apply(
c.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&params),
did,
)
.send()
.await
.unwrap()
}
async fn post_ingest(c: &reqwest::Client, body: Value) -> reqwest::Response {
c.post(format!("{APPVIEW_URL}/internal/ingest-commit"))
.json(&body)
.send()
.await
.unwrap()
}
fn did_for_test(prefix: &str) -> String {
format!(
"did:plc:emb_{}_{}",
prefix,
uuid::Uuid::new_v4().simple()
)
}
fn rkey() -> String {
uuid::Uuid::new_v4().simple().to_string()
}
/// Seed a single post with the given record payload and return its URI.
async fn seed_post(c: &reqwest::Client, did: &str, record: Value) -> String {
let rk = rkey();
let uri = format!("at://{did}/app.twi.post/{rk}");
let resp = post_ingest(
c,
json!({
"did": did,
"collection": "app.twi.post",
"action": "create",
"rkey": rk,
"cid": "bafyreicid",
"record": record,
}),
)
.await;
assert_eq!(resp.status().as_u16(), 200, "ingest failed: {record}");
uri
}
/// Seed a post whose parent/root URIs are given explicitly. Used by
/// the thread test to build a 3-deep chain (root → reply → reply).
async fn seed_reply(
c: &reqwest::Client,
did: &str,
text: &str,
parent_uri: &str,
root_uri: &str,
) -> String {
seed_post(
c,
did,
json!({
"text": text,
"createdAt": "2026-07-01T12:00:00Z",
"reply": {
"parent": {"uri": parent_uri, "cid": "cp"},
"root": {"uri": root_uri, "cid": "cr"}
}
}),
)
.await
}
#[tokio::test]
async fn timeline_includes_embed() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let Some(auth) = auth_or_skip().await else { return };
let did = did_for_test("img");
let uri = seed_post(
&c,
&did,
json!({
"text": "look at this image",
"createdAt": "2026-07-01T12:00:00Z",
"embed": {
"$type": "app.bsky.embed.images",
"images": [
{
"alt": "a sunset over mountains",
"image": {
"$type": "blob",
"ref": {"$link": "bafyreimgres1"},
"mimeType": "image/jpeg",
"size": 12345
},
"aspectRatio": {"width": 1200, "height": 800}
},
{
"alt": "second image",
"image": {
"$type": "blob",
"ref": {"$link": "bafyreimgres2"},
"mimeType": "image/jpeg",
"size": 6789
}
}
]
}
}),
)
.await;
let resp = get_timeline(&c, &auth, &did, &[("limit", "10")]).await;
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().unwrap();
let our = posts
.iter()
.find(|p| p["uri"] == json!(uri))
.expect("seeded post missing from timeline");
let embed = our
.get("embed")
.expect("embed field missing from PostRow");
assert!(!embed.is_null(), "embed must not be null for image post");
assert_eq!(embed["$type"], "app.bsky.embed.images");
let imgs = embed["images"].as_array().expect("images array");
assert_eq!(imgs.len(), 2);
assert_eq!(imgs[0]["alt"], "a sunset over mountains");
assert_eq!(imgs[0]["image"]["ref"]["$link"], "bafyreimgres1");
assert_eq!(imgs[0]["aspectRatio"]["width"], 1200);
assert_eq!(imgs[1]["alt"], "second image");
}
#[tokio::test]
async fn timeline_includes_external_embed() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let Some(auth) = auth_or_skip().await else { return };
let did = did_for_test("ext");
let uri = seed_post(
&c,
&did,
json!({
"text": "see link",
"createdAt": "2026-07-01T12:00:00Z",
"embed": {
"$type": "app.bsky.embed.external",
"external": {
"uri": "https://example.com/article",
"title": "An interesting article",
"description": "A short description of the linked page.",
"thumb": {
"$type": "blob",
"ref": {"$link": "bafyreithumb"}
}
}
}
}),
)
.await;
let resp = get_timeline(&c, &auth, &did, &[("limit", "10")]).await;
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().unwrap();
// The ingest endpoint commits asynchronously; the timeline may
// not yet contain the row on the first poll. Retry briefly with
// a 50ms back-off so we don't flake on busy CI.
let mut our = posts.iter().find(|p| p["uri"] == json!(uri)).cloned();
for _ in 0..10 {
if our.is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
let resp = get_timeline(&c, &auth, &did, &[("limit", "10")]).await;
let body: Value = resp.json().await.unwrap();
our = body["posts"]
.as_array()
.unwrap()
.iter()
.find(|p| p["uri"] == json!(uri))
.cloned();
}
let our = our.expect("seeded post missing from timeline");
let embed = our["embed"].as_object().expect("embed object");
assert_eq!(embed["$type"], "app.bsky.embed.external");
assert_eq!(embed["external"]["uri"], "https://example.com/article");
assert_eq!(embed["external"]["title"], "An interesting article");
}
#[tokio::test]
async fn post_endpoint_returns_thread() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let alice = did_for_test("thread_alice");
let bob = did_for_test("thread_bob");
let carol = did_for_test("thread_carol");
// Build the chain: root (alice) → reply (bob) → reply to reply (carol).
let root_uri = seed_post(
&c,
&alice,
json!({
"text": "alice's root post",
"createdAt": "2026-07-01T12:00:00Z"
}),
)
.await;
let reply1_uri = seed_reply(
&c,
&bob,
"bob's reply to alice",
&root_uri,
&root_uri,
)
.await;
let reply2_uri = seed_reply(
&c,
&carol,
"carol's reply to bob",
&reply1_uri,
&root_uri,
)
.await;
// Fetch carol's post and verify the thread hydration returns both
// bob's reply (parent) and alice's root (root).
let resp = c
.get(format!("{APPVIEW_URL}/api/post/{reply2_uri}"))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["post"]["uri"], json!(reply2_uri));
assert_eq!(
body["post"]["text"],
json!("carol's reply to bob")
);
let parent = &body["thread"]["parent"];
let root = &body["thread"]["root"];
assert_eq!(parent["uri"], json!(reply1_uri));
assert_eq!(parent["text"], json!("bob's reply to alice"));
assert_eq!(root["uri"], json!(root_uri));
assert_eq!(root["text"], json!("alice's root post"));
// Reply → reply case: carol's `parent_uri` is bob's, `root_uri` is
// alice's, and they must differ — so the root field must NOT be
// collapsed into the parent field.
assert_ne!(
parent["uri"], root["uri"],
"root and parent must be distinct rows for a 2-deep reply chain"
);
}
#[tokio::test]
async fn post_endpoint_single_post_thread_self_referential() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let did = did_for_test("self");
let uri = seed_post(
&c,
&did,
json!({
"text": "standalone post, no parent",
"createdAt": "2026-07-01T12:00:00Z"
}),
)
.await;
let resp = c
.get(format!("{APPVIEW_URL}/api/post/{uri}"))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["post"]["uri"], json!(uri));
assert!(
body["thread"]["parent"].is_null(),
"post with no parent must have null parent"
);
assert!(
body["thread"]["root"].is_null(),
"post with no parent must have null root"
);
}
#[tokio::test]
async fn post_endpoint_unknown_uri_returns_null_post() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
let c = client().await;
let bogus = format!(
"at://did:plc:nope-{}/app.twi.post/nope-{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
);
let resp = c
.get(format!("{APPVIEW_URL}/api/post/{bogus}"))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert!(body["post"].is_null());
assert!(body["thread"]["parent"].is_null());
assert!(body["thread"]["root"].is_null());
}
#[tokio::test]
async fn timeline_post_without_embed_has_null_embed() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let Some(auth) = auth_or_skip().await else { return };
let did = did_for_test("plain");
let uri = seed_post(
&c,
&did,
json!({
"text": "plain text only",
"createdAt": "2026-07-01T12:00:00Z"
}),
)
.await;
let resp = get_timeline(&c, &auth, &did, &[("limit", "10")]).await;
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
let our = body["posts"]
.as_array()
.unwrap()
.iter()
.find(|p| p["uri"] == json!(uri))
.expect("plain post missing");
assert!(
our["embed"].is_null(),
"plain text post must have null embed, got: {}",
our["embed"]
);
}