Files
benilla-octo/crates/benilla-app/src/ui_script/server_alert_tests.rs
T
Sam SungandClaude Sonnet 5.5 2af917044d Stock login fetches the server alert; LaunchURL behind the gate (#1040)
The stock login's Breaking News box (`WOW_GLUE_STOCK=1`):

- At glue init, as `0x46a400` does, the client reads the Lua global `SERVER_ALERT_URL`, which the player's own GlueStrings or a server's patch sets. It fetches the URL once on a background thread: plain HTTP, 5 s timeouts, redirects followed.
- The body builds the reference's buffer (`0x46a580`: 0x7ff cap, appended chunks). A case-insensitive `SERVERALERT:` prefix fires `SHOW_SERVER_ALERT` once from the update (`0x46b939`). Every failure is silent.
- `LaunchURL` opens the browser only for URLs that pass the reference's gate (`0x5abc10`): `http://`, a strict charset and the 10-host whitelist. An over-long host is refused rather than cut.
- No fetch or browser ever runs in a test, capture or unattended run.

Tests run against a local fake server: prefix, 404, redirects, chunked, timeouts, caps, and the frame shown end to end.

Part of #1026

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:42:29 +07:00

420 lines
14 KiB
Rust

use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use super::*;
/// Short enough that the timeout test is quick.
const FAST: Timeouts = Timeouts {
connect: Duration::from_millis(500),
read: Duration::from_millis(300),
};
// ── The buffer, chunk by chunk ──
#[test]
fn a_chunk_loses_its_last_byte_and_stops_at_a_nul() {
let mut b = AlertBuffer::default();
b.push(b"SERVERALERT:abX");
assert_eq!(b.buf, b"SERVERALERT:ab");
// One byte leaves nothing (`SStrCopy` of size 1), and an empty read adds nothing.
let mut b = AlertBuffer::default();
b.push(b"X");
b.push(b"");
assert!(b.buf.is_empty());
let mut b = AlertBuffer::default();
b.push(b"ab\0cdX");
assert_eq!(b.buf, b"ab");
}
/// The callback appends (`SStrPack`), so a body read in pieces is the pieces joined, each short
/// of its last byte.
#[test]
fn chunks_add_up_to_the_buffer_and_stop_at_its_cap() {
let mut b = AlertBuffer::default();
b.push(b"SERVERALERT:abX");
b.push(b"cdY");
assert_eq!(b.finish().as_deref(), Some(&b"abcd"[..]));
let mut b = AlertBuffer::default();
b.push(b"SERVERALERT:");
b.push(&vec![b'a'; 0x4000]);
assert_eq!(b.buf.len(), 0x7ff, "0x800 with the terminator");
assert!(b.is_full());
b.push(b"more");
assert_eq!(b.buf.len(), 0x7ff);
// A chunk of exactly 0x7ff or 0x800 bytes fills it the same way.
for len in [0x7ff, 0x800, 0x801] {
let mut b = AlertBuffer::default();
b.push(&vec![b'a'; len]);
assert_eq!(b.buf.len(), (len.min(0x800) - 1), "{len}");
}
}
#[test]
fn the_prefix_is_case_folded_and_must_be_all_there() {
let alert = |body: &[u8]| {
let mut b = AlertBuffer::default();
b.push(body);
b.finish()
};
assert_eq!(alert(b"serverAlert:hi\n"), Some(b"hi".to_vec()));
assert_eq!(alert(b"SERVERALERT:\n"), Some(Vec::new()));
assert_eq!(alert(b"SERVERALERT\n"), None, "no colon");
assert_eq!(alert(b"SERVERALERT"), None, "a prefix cut short");
assert_eq!(alert(b" SERVERALERT:hi\n"), None, "not at the start");
assert_eq!(alert(b"<html>SERVERALERT:</html>"), None);
assert_eq!(alert(b""), None);
}
// ── The URL ──
#[test]
fn urls_split_into_host_port_and_target() {
let url = |s| parse_url(s).unwrap();
assert_eq!(
url("http://launcher.worldofwarcraft.com/alert"),
Url {
host: "launcher.worldofwarcraft.com".into(),
port: 80,
target: "/alert".into()
}
);
assert_eq!(url("HTTP://h:8080").target, "/");
assert_eq!(url("http://h:8080?x=1#frag").target, "/?x=1");
assert_eq!(url("http://h:8080?x=1").port, 8080);
assert_eq!(url("http://u:p@h/a/b?c#d").host, "h");
let v6 = url("http://[::1]:81/p");
assert_eq!((v6.host.as_str(), v6.port), ("::1", 81));
assert!(matches!(parse_url("https://h/"), Err(FetchError::Https)));
assert!(matches!(parse_url("HTTPS://h/"), Err(FetchError::Https)));
for bad in [
"",
"ftp://h/",
"h/alert",
"http://",
"http://:80/",
"http://h:x/",
] {
assert!(matches!(parse_url(bad), Err(FetchError::BadUrl)), "{bad}");
}
}
#[test]
fn a_location_resolves_against_the_request() {
let base = parse_url("http://h:81/a/b?q").unwrap();
let go = |l| resolve(&base, l).unwrap();
assert_eq!(go("/c").target, "/c");
assert_eq!(go("c").target, "/a/c");
assert_eq!(go("http://other/x").host, "other");
assert_eq!(go("//other:9/x").port, 9);
assert_eq!((go("/c").host.as_str(), go("/c").port), ("h", 81));
assert!(matches!(
resolve(&base, "https://h/"),
Err(FetchError::Https)
));
}
// ── The fetch, against a fake server ──
/// What a fake server does with a connection.
enum Reply {
/// Write these bytes and close.
Raw(Vec<u8>),
/// Accept and say nothing.
Silent,
/// Write these bytes and then say nothing, the connection open.
Stall(Vec<u8>),
}
/// A server on a loopback port: each connection's request head goes to `handler` with the
/// connection's number, and the heads are kept.
struct Fake {
port: u16,
heads: Arc<Mutex<Vec<String>>>,
}
impl Fake {
fn start(handler: impl Fn(&str, usize, u16) -> Reply + Send + 'static) -> Fake {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let heads = Arc::new(Mutex::new(Vec::new()));
let seen = heads.clone();
std::thread::spawn(move || {
for (n, conn) in listener.incoming().enumerate() {
let Ok(mut conn) = conn else { return };
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") {
if conn.read(&mut byte).unwrap_or(0) == 0 {
break;
}
head.push(byte[0]);
}
let head = String::from_utf8_lossy(&head).into_owned();
let reply = handler(&head, n, port);
seen.lock().unwrap().push(head);
match reply {
Reply::Raw(bytes) => {
let _ = conn.write_all(&bytes);
}
Reply::Silent => std::thread::sleep(Duration::from_secs(2)),
Reply::Stall(bytes) => {
let _ = conn.write_all(&bytes);
std::thread::sleep(Duration::from_secs(2));
}
}
}
});
Fake { port, heads }
}
fn url(&self, path: &str) -> String {
format!("http://127.0.0.1:{}{path}", self.port)
}
fn requests(&self) -> usize {
self.heads.lock().unwrap().len()
}
}
fn ok(body: &[u8]) -> Reply {
let mut raw = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()).into_bytes();
raw.extend_from_slice(body);
Reply::Raw(raw)
}
fn alert(url: &str) -> Option<String> {
fetch_alert(url, FAST).map(|t| String::from_utf8_lossy(&t).into_owned())
}
#[test]
fn a_200_with_the_prefix_is_an_alert_and_the_request_is_the_references() {
let fake = Fake::start(|_, _, _| ok(b"SERVERALERT:\nRealms are down.\n"));
assert_eq!(
alert(&fake.url("/alert?x=1")).as_deref(),
// The last byte, the final newline, is the callback's to lose.
Some("\nRealms are down.")
);
let head = fake.heads.lock().unwrap()[0].clone();
assert!(head.starts_with("GET /alert?x=1 HTTP/1.1\r\n"), "{head}");
assert!(head.contains(&format!("Host: 127.0.0.1:{}\r\n", fake.port)));
assert!(head.contains("User-Agent: Blizzard Web Client\r\n"));
}
#[test]
fn a_200_without_the_prefix_is_no_alert() {
let fake = Fake::start(|_, _, _| ok(b"<html>Not found, really.</html>\n"));
assert_eq!(alert(&fake.url("/")), None);
}
#[test]
fn a_404_is_no_alert_even_with_the_prefix_in_its_body() {
let body = b"SERVERALERT:\nnot for you\n";
let fake = Fake::start(move |_, _, _| {
let mut raw = format!(
"HTTP/1.1 404 Not Found\r\nContent-Length: {}\r\n\r\n",
body.len()
)
.into_bytes();
raw.extend_from_slice(body);
Reply::Raw(raw)
});
assert_eq!(alert(&fake.url("/")), None);
}
#[test]
fn redirects_are_followed_up_to_the_limit() {
let fake = Fake::start(|head, _, port| {
let to = |loc: String| {
Reply::Raw(
format!("HTTP/1.1 302 Found\r\nLocation: {loc}\r\nContent-Length: 0\r\n\r\n")
.into_bytes(),
)
};
if head.starts_with("GET /a ") {
to("/b".into())
} else if head.starts_with("GET /b ") {
to(format!("http://127.0.0.1:{port}/c"))
} else if head.starts_with("GET /loop ") {
to("/loop".into())
} else {
ok(b"SERVERALERT:moved\n")
}
});
assert_eq!(alert(&fake.url("/a")).as_deref(), Some("moved"));
assert_eq!(fake.requests(), 3);
let before = fake.requests();
assert_eq!(alert(&fake.url("/loop")), None);
assert_eq!(fake.requests() - before, MAX_REDIRECTS + 1);
}
#[test]
fn a_redirect_to_https_is_not_followed() {
let fake = Fake::start(|_, _, _| {
Reply::Raw(
b"HTTP/1.1 301 Moved\r\nLocation: https://127.0.0.1:1/alert\r\nContent-Length: 0\r\n\r\n"
.to_vec(),
)
});
assert!(matches!(
download(&fake.url("/"), FAST, &mut |_| true),
Err(FetchError::Https)
));
assert_eq!(alert(&fake.url("/")), None);
}
#[test]
fn a_chunked_body_is_decoded() {
let fake = Fake::start(|_, _, _| {
Reply::Raw(
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n\
7\r\nSERVERA\r\n8;ext=1\r\nLERT:hi!\r\n3\r\n\nyo\r\n0\r\nTrailer: x\r\n\r\n"
.to_vec(),
)
});
// One read of the whole body: the decoded bytes, short of the last.
assert_eq!(alert(&fake.url("/")).as_deref(), Some("hi!\ny"));
}
#[test]
fn a_body_with_no_length_is_read_to_the_close() {
let fake = Fake::start(|_, _, _| {
Reply::Raw(b"HTTP/1.0 200 OK\r\nServer: x\r\n\r\nSERVERALERT:to the end\n".to_vec())
});
assert_eq!(alert(&fake.url("/")).as_deref(), Some("to the end"));
}
#[test]
fn a_server_that_says_nothing_times_out() {
let fake = Fake::start(|_, _, _| Reply::Silent);
let start = Instant::now();
assert_eq!(alert(&fake.url("/")), None);
assert!(start.elapsed() < Duration::from_millis(1500));
}
/// The status and the first bytes, then silence past the read timeout: the done call
/// (`0x7a6c4b`) runs whatever ended the reads, and finds the prefix.
#[test]
fn a_body_that_stalls_keeps_what_came_before_it() {
let fake = Fake::start(|_, _, _| {
Reply::Stall(
b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nSERVERALERT:partial\nmore".to_vec(),
)
});
let start = Instant::now();
assert_eq!(alert(&fake.url("/")).as_deref(), Some("partial\nmor"));
assert!(start.elapsed() < Duration::from_millis(1500));
}
#[test]
fn a_3_kb_body_fills_the_buffer_and_no_more() {
let mut body = b"SERVERALERT:".to_vec();
body.extend((0..3000).map(|i| b'a' + (i % 26) as u8));
let fake = Fake::start(move |_, _, _| ok(&body.clone()));
let text = fetch_alert(&fake.url("/"), FAST).unwrap();
assert_eq!(text.len(), 0x7ff - PREFIX.len());
assert!(text.starts_with(b"abcdefghij"));
}
#[test]
fn an_https_url_is_never_dialled() {
let fake = Fake::start(|_, _, _| ok(b"SERVERALERT:x"));
let https = fake.url("/").replacen("http://", "https://", 1);
assert!(matches!(
download(&https, FAST, &mut |_| true),
Err(FetchError::Https)
));
assert_eq!(alert(&https), None);
assert_eq!(fake.requests(), 0);
}
#[test]
fn a_refused_connection_is_silent() {
let port = {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
assert_eq!(alert(&format!("http://127.0.0.1:{port}/")), None);
assert!(TcpStream::connect(("127.0.0.1", port)).is_err());
}
/// The fetch runs on a thread of its own, so the caller never waits on it.
#[test]
fn the_background_fetch_hands_over_its_alert_through_a_channel() {
let fake = Fake::start(|_, _, _| ok(b"SERVERALERT:\nhello\n"));
let rx = spawn_fetch(fake.url("/"));
let got = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(got.as_deref(), Some(&b"\nhello"[..]));
}
/// A test run starts no fetch and opens no browser, whatever the URL.
#[test]
fn a_test_run_neither_fetches_nor_opens_a_browser() {
assert!(!allowed());
assert!(begin_once("http://127.0.0.1:1/").is_none());
}
/// A writer that records each call, to see how the request leaves.
#[derive(Default)]
struct Calls(Vec<Vec<u8>>);
impl Write for Calls {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.push(buf.to_vec());
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn the_request_head_leaves_in_one_write() {
let mut calls = Calls::default();
send_request(&mut calls, &parse_url("http://h:81/a?b").unwrap()).unwrap();
assert_eq!(calls.0.len(), 1);
let head = String::from_utf8(calls.0.remove(0)).unwrap();
assert!(
head.starts_with("GET /a?b HTTP/1.1\r\nHost: h:81\r\n"),
"{head}"
);
assert!(head.ends_with("\r\n\r\n"));
}
#[test]
fn an_ipv6_host_is_bracketed_in_the_host_header() {
let mut calls = Calls::default();
send_request(&mut calls, &parse_url("http://[::1]/").unwrap()).unwrap();
let head = String::from_utf8(calls.0.concat()).unwrap();
assert!(head.contains("\r\nHost: [::1]\r\n"), "{head}");
calls.0.clear();
send_request(&mut calls, &parse_url("http://[::1]:8080/").unwrap()).unwrap();
assert!(String::from_utf8(calls.0.concat())
.unwrap()
.contains("\r\nHost: [::1]:8080\r\n"));
}
/// A chunked body whose trailers never end is refused, not read forever.
#[test]
fn endless_chunked_trailers_are_refused() {
let mut raw = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n".to_vec();
for _ in 0..MAX_HEADERS + 5 {
raw.extend_from_slice(b"X-Trailer: y\r\n");
}
raw.extend_from_slice(b"\r\n");
let fake = Fake::start(move |_, _, _| Reply::Raw(raw.clone()));
assert!(matches!(
download(&fake.url("/"), FAST, &mut |_| true),
Err(FetchError::Io(_))
));
// The ordinary trailer still ends it.
let fake = Fake::start(|_, _, _| {
Reply::Raw(
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n0\r\nX: y\r\n\r\n".to_vec(),
)
});
assert!(download(&fake.url("/"), FAST, &mut |_| true).is_ok());
}