crates/gunnar-public-site/tests/indexnow.rs
//! **IndexNow** (owner 2026-10-25): with its OWN switch on (an IndexNow key in
//! the config), the site holds the key file at `/{key}.txt` and tells
//! api.indexnow.org which public URLs changed (a new public repository, a new
//! commit on one, the home), at most once per interval. The owner's AIRGAP
//! rule: without that switch the site makes no outbound call at all, whatever
//! else is on.
mod common;
use common::*;
use std::sync::Arc;
use std::time::Duration;
use gunnar_public_site::{indexnow_from, IndexNow, Site};
use http::{header, StatusCode};
const KEY: &str = "0123456789abcdef0123456789abcdef";
fn on(min_interval: Duration) -> gunnar_public_site::Config {
let mut c = config();
let mut i = IndexNow::new(KEY);
i.min_interval = min_interval;
c.indexnow = Some(i);
c
}
async fn site_pinging(config: gunnar_public_site::Config) -> (Arc<Site>, Arc<Pings>, Arc<Fake>) {
let pings = Pings::new();
let fake = Arc::new(Fake::new());
let site = Site::new(config, fake.clone(), hooks_pinging(pings.clone()), true);
site.refresh().await.expect("the first refresh");
(site, pings, fake)
}
#[tokio::test]
async fn off_by_default_the_site_makes_no_outbound_call() {
assert!(gunnar_public_site::Config::gunnar("https://gunnar.example").indexnow.is_none(), "IndexNow is on by default");
let (site, pings, _) = site_pinging(config()).await;
site.refresh().await.unwrap();
assert_eq!(pings.count(), 0, "a ping went out with IndexNow off");
let a = get(&app(site), &format!("/{KEY}.txt")).await;
assert_eq!(a.body, TODAY, "the key file is served with IndexNow off");
}
#[test]
fn the_switch_is_the_key_and_only_a_valid_key_turns_it_on() {
assert!(indexnow_from(None).is_none());
assert!(indexnow_from(Some("")).is_none());
assert!(indexnow_from(Some("short")).is_none(), "IndexNow keys are 8 to 128 characters");
assert!(indexnow_from(Some("not a key; rm -rf")).is_none());
let i = indexnow_from(Some(KEY)).expect("a valid key");
assert_eq!(i.key, KEY);
assert_eq!(i.endpoint, "https://api.indexnow.org/indexnow");
assert!(i.min_interval >= Duration::from_secs(60), "{:?}", i.min_interval);
}
#[tokio::test]
async fn with_a_key_the_key_file_is_served_and_the_public_urls_are_announced() {
let (site, pings, _) = site_pinging(on(Duration::ZERO)).await;
let a = get(&app(site), &format!("/{KEY}.txt")).await;
assert_eq!(a.status, StatusCode::OK);
assert!(a.headers[header::CONTENT_TYPE].to_str().unwrap().starts_with("text/plain"));
assert_eq!(a.body, KEY);
assert_eq!(pings.count(), 1);
let (endpoint, json) = pings.sent.lock().unwrap()[0].clone();
assert_eq!(endpoint, "https://api.indexnow.org/indexnow");
let body: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(body["host"], "gunnar.example");
assert_eq!(body["key"], KEY);
assert_eq!(body["keyLocation"], format!("https://gunnar.example/{KEY}.txt"));
let urls = pings.urls(0);
assert!(urls.contains(&"https://gunnar.example/".to_owned()) && urls.contains(&format!("https://gunnar.example/{NS}/{PUBLIC}")), "{urls:?}");
assert!(!json.contains(PRIVATE), "a ping named the private repository: {json}");
}
#[tokio::test]
async fn a_new_commit_announces_only_its_repository() {
let (site, pings, fake) = site_pinging(on(Duration::ZERO)).await;
site.refresh().await.unwrap();
assert_eq!(pings.count(), 1, "nothing changed, nothing is announced");
*fake.head.lock().unwrap() = "ff".repeat(20);
site.refresh().await.unwrap();
assert_eq!(pings.count(), 2);
assert_eq!(pings.urls(1), vec![format!("https://gunnar.example/{NS}/{PUBLIC}")]);
}
#[tokio::test]
async fn pings_are_rate_limited_and_nothing_is_lost() {
let (site, pings, fake) = site_pinging(on(Duration::from_secs(3600))).await;
*fake.head.lock().unwrap() = "ff".repeat(20);
site.refresh().await.unwrap();
assert_eq!(pings.count(), 1, "a second ping inside the interval");
assert_eq!(site.indexnow_pending(), vec![format!("https://gunnar.example/{NS}/{PUBLIC}")], "the change waits for the next ping");
}
#[tokio::test]
async fn a_failed_ping_keeps_its_urls_for_the_next() {
let pings = Pings::new();
*pings.status.lock().unwrap() = Err("no route to host".into());
let site = Site::new(on(Duration::ZERO), Arc::new(Fake::new()), hooks_pinging(pings.clone()), true);
site.refresh().await.unwrap();
assert_eq!(pings.count(), 1);
assert!(site.indexnow_pending().contains(&"https://gunnar.example/".to_owned()));
*pings.status.lock().unwrap() = Ok(202);
site.refresh().await.unwrap();
assert_eq!(pings.count(), 2);
assert!(pings.urls(1).contains(&"https://gunnar.example/".to_owned()));
assert!(site.indexnow_pending().is_empty());
}
#[tokio::test]
async fn a_visitor_never_causes_a_ping() {
let (site, pings, _) = site_pinging(on(Duration::ZERO)).await;
let app = app(site);
for p in ["/", "/sitemap.xml", "/llms.txt", &format!("/{NS}/{PUBLIC}")] {
get(&app, p).await;
}
assert_eq!(pings.count(), 1, "only the refresh announces");
}
#[tokio::test]
async fn with_the_site_switched_off_nothing_is_announced() {
let pings = Pings::new();
let site = Site::new(on(Duration::ZERO), Arc::new(Fake::new()), hooks_pinging(pings.clone()), false);
site.refresh().await.unwrap();
assert_eq!(pings.count(), 0);
}
/// ★ A32-16: **IndexNow is set and cleared at run time** (the console's stored setting, never
/// only an environment key read once): a key set on the running site serves its key file at
/// once and is announced by the next refresh; cleared, the key file goes and nothing more is
/// announced.
#[tokio::test]
async fn the_key_is_set_and_cleared_on_the_running_site() {
let (site, pings, fake) = site_pinging(config()).await;
assert!(site.indexnow().is_none(), "IndexNow is on with no key set");
let mut i = IndexNow::new(KEY);
i.min_interval = Duration::ZERO;
site.set_indexnow(Some(i));
assert_eq!(site.indexnow().map(|i| i.key), Some(KEY.to_owned()));
let a = get(&app(site.clone()), &format!("/{KEY}.txt")).await;
assert_eq!((a.status, a.body.as_str()), (StatusCode::OK, KEY), "the key file is served once the key is set");
site.refresh().await.unwrap();
assert_eq!(pings.count(), 1, "the next refresh announces the public URLs");
site.set_indexnow(None);
let a = get(&app(site.clone()), &format!("/{KEY}.txt")).await;
assert_ne!(a.body, KEY, "the key file is still served after the key was cleared");
*fake.head.lock().unwrap() = "ff".repeat(20);
site.refresh().await.unwrap();
assert_eq!(pings.count(), 1, "a ping went out after the key was cleared");
}