use std::fs;
use std::process::{Command, Stdio};
use tempfile::tempdir;
fn guidebook_bin() -> String {
let mut path = std::env::current_exe().unwrap();
path.pop(); path.pop(); path.push("guidebook");
path.to_string_lossy().to_string()
}
fn create_test_book(dir: &std::path::Path) {
fs::write(
dir.join("book.json"),
r#"{
"title": "Test Book",
"description": "A test book",
"author": "Test Author"
}"#,
)
.unwrap();
fs::write(
dir.join("SUMMARY.md"),
r#"# Summary
* [Introduction](README.md)
* [Chapter 1](chapter1.md)
* [Chapter 2](chapter2/README.md)
"#,
)
.unwrap();
fs::write(
dir.join("README.md"),
r#"# Test Book
Welcome to the test book!
This has a [link to chapter 1](chapter1.md).
"#,
)
.unwrap();
fs::write(
dir.join("chapter1.md"),
r#"# Chapter 1
This is chapter 1 content.
## Section 1.1
Some details here.
```rust
fn main() {
println!("Hello, world!");
}
```
"#,
)
.unwrap();
fs::create_dir_all(dir.join("chapter2")).unwrap();
fs::write(
dir.join("chapter2/README.md"),
r#"# Chapter 2
Nested chapter content.
"#,
)
.unwrap();
fs::create_dir_all(dir.join("assets")).unwrap();
fs::write(dir.join("assets/test.txt"), "test asset content").unwrap();
}
#[test]
fn test_full_build() {
let temp = tempdir().unwrap();
let source = temp.path().join("book");
let output = temp.path().join("output");
fs::create_dir_all(&source).unwrap();
create_test_book(&source);
let status = Command::new(guidebook_bin())
.arg("build")
.arg(source.to_str().unwrap())
.arg("-o")
.arg(output.to_str().unwrap())
.status()
.expect("Failed to execute guidebook build");
assert!(status.success(), "Build should succeed");
let index = output.join("index.html");
assert!(index.exists(), "index.html should be generated");
let index_content = fs::read_to_string(&index).unwrap();
assert!(
index_content.contains("Test Book"),
"index.html should contain the book title"
);
let ch1 = output.join("chapter1.html");
assert!(ch1.exists(), "chapter1.html should be generated");
let ch1_content = fs::read_to_string(&ch1).unwrap();
assert!(
ch1_content.contains("Chapter 1"),
"chapter1.html should contain the chapter title"
);
let ch2 = output.join("chapter2/README.html");
assert!(ch2.exists(), "chapter2/README.html should be generated");
let css = output.join("gitbook/gitbook.css");
assert!(css.exists(), "gitbook.css should be generated");
let js = output.join("gitbook/gitbook.js");
assert!(js.exists(), "gitbook.js should be generated");
let search_index = output.join("search_index.json");
assert!(
search_index.exists(),
"search_index.json should be generated"
);
let search_content = fs::read_to_string(&search_index).unwrap();
let search_json: serde_json::Value = serde_json::from_str(&search_content).unwrap();
assert!(
search_json.is_array(),
"Search index should be a JSON array"
);
assert!(
search_json.as_array().unwrap().len() >= 2,
"Search index should have entries for chapters"
);
let asset = output.join("assets/test.txt");
assert!(
asset.exists() || output.join("assets").join("test.txt").read_link().is_ok(),
"Asset file should be copied or symlinked"
);
}
#[test]
fn test_build_generates_valid_html() {
let temp = tempdir().unwrap();
let source = temp.path().join("book");
let output = temp.path().join("output");
fs::create_dir_all(&source).unwrap();
create_test_book(&source);
let status = Command::new(guidebook_bin())
.arg("build")
.arg(source.to_str().unwrap())
.arg("-o")
.arg(output.to_str().unwrap())
.status()
.expect("Failed to execute guidebook build");
assert!(status.success());
let index = fs::read_to_string(output.join("index.html")).unwrap();
assert!(
index.contains("<!DOCTYPE html>") || index.contains("<!DOCTYPE HTML>"),
"Should have DOCTYPE declaration"
);
assert!(index.contains("<html"), "Should have html tag");
assert!(index.contains("</html>"), "Should have closing html tag");
assert!(
index.contains("<head>") || index.contains("<head "),
"Should have head tag"
);
assert!(index.contains("<body"), "Should have body tag");
assert!(index.contains("</body>"), "Should have closing body tag");
}
#[test]
fn test_build_with_code_blocks() {
let temp = tempdir().unwrap();
let source = temp.path().join("book");
let output = temp.path().join("output");
fs::create_dir_all(&source).unwrap();
create_test_book(&source);
let status = Command::new(guidebook_bin())
.arg("build")
.arg(source.to_str().unwrap())
.arg("-o")
.arg(output.to_str().unwrap())
.status()
.expect("Failed to execute guidebook build");
assert!(status.success());
let ch1 = fs::read_to_string(output.join("chapter1.html")).unwrap();
assert!(
ch1.contains("<pre>") || ch1.contains("<code"),
"Code blocks should be rendered as <pre><code>"
);
}
#[test]
fn test_init_command() {
let temp = tempdir().unwrap();
let book_dir = temp.path().join("new-book");
let status = Command::new(guidebook_bin())
.arg("init")
.arg(book_dir.to_str().unwrap())
.status()
.expect("Failed to execute guidebook init");
assert!(status.success(), "Init should succeed");
assert!(
book_dir.join("README.md").exists(),
"README.md should be created"
);
assert!(
book_dir.join("SUMMARY.md").exists(),
"SUMMARY.md should be created"
);
assert!(
book_dir.join("book.json").exists(),
"book.json should be created"
);
let output = temp.path().join("output");
let status = Command::new(guidebook_bin())
.arg("build")
.arg(book_dir.to_str().unwrap())
.arg("-o")
.arg(output.to_str().unwrap())
.status()
.expect("Failed to build initialized book");
assert!(status.success(), "Building initialized book should succeed");
assert!(
output.join("index.html").exists(),
"Built book should have index.html"
);
}
#[test]
fn test_sidebar_navigation() {
let temp = tempdir().unwrap();
let source = temp.path().join("book");
let output = temp.path().join("output");
fs::create_dir_all(&source).unwrap();
create_test_book(&source);
let status = Command::new(guidebook_bin())
.arg("build")
.arg(source.to_str().unwrap())
.arg("-o")
.arg(output.to_str().unwrap())
.status()
.expect("Failed to execute guidebook build");
assert!(status.success());
let index = fs::read_to_string(output.join("index.html")).unwrap();
assert!(
index.contains("chapter1"),
"Sidebar should link to chapter1"
);
assert!(
index.contains("Chapter 1"),
"Sidebar should display chapter title"
);
}
fn find_available_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
fn start_serve(source: &std::path::Path, port: u16) -> std::process::Child {
Command::new(guidebook_bin())
.arg("serve")
.arg(source.to_str().unwrap())
.arg("-p")
.arg(port.to_string())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("Failed to start guidebook serve")
}
fn wait_for_server(port: u16) -> bool {
for _ in 0..100 {
if std::net::TcpStream::connect(format!("127.0.0.1:{}", port)).is_ok() {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
false
}
#[test]
fn test_serve_rejects_path_traversal() {
let temp = tempdir().unwrap();
let source = temp.path().join("book");
fs::create_dir_all(&source).unwrap();
create_test_book(&source);
fs::write(temp.path().join("secret.txt"), "TOP SECRET").unwrap();
let port = find_available_port();
let mut child = start_serve(&source, port);
if !wait_for_server(port) {
child.kill().ok();
panic!(
"Server did not start on port {} within 20s — security tests cannot run",
port
);
}
let client = reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let traversal_paths = vec![
"/../secret.txt",
"/..%2Fsecret.txt",
"/%2e%2e/secret.txt",
"/%2e%2e%2fsecret.txt",
"/sub/../../secret.txt",
"/..\\secret.txt",
];
for path in &traversal_paths {
let url = format!("http://127.0.0.1:{}{}", port, path);
match client.get(&url).send() {
Ok(resp) => {
let status = resp.status().as_u16();
let body = resp.text().unwrap_or_default();
assert!(
status == 403 || status == 404,
"Path traversal '{}' should be blocked (got {} with body: {})",
path,
status,
&body[..body.len().min(100)]
);
assert!(
!body.contains("TOP SECRET"),
"Path traversal '{}' leaked secret content!",
path
);
}
Err(_) => {
}
}
}
let resp = client
.get(format!("http://127.0.0.1:{}/", port))
.send()
.unwrap();
assert_eq!(resp.status().as_u16(), 200, "Normal access should work");
let url = format!("http://127.0.0.1:{}/..%252F..%252Fetc/passwd", port);
if let Ok(resp) = client.get(&url).send() {
let status = resp.status().as_u16();
assert!(
status == 403 || status == 404,
"Double-encoded traversal should be blocked (got {})",
status
);
}
child.kill().ok();
child.wait().ok();
}
fn create_multilang_book(dir: &std::path::Path) {
fs::write(
dir.join("LANGS.md"),
"* [English](en/)\n* [Japanese](ja/)\n",
)
.unwrap();
fs::write(dir.join("book.json"), r#"{"title": "Multi-lang Book"}"#).unwrap();
let en = dir.join("en");
fs::create_dir_all(&en).unwrap();
fs::write(en.join("README.md"), "# English Intro\nWelcome\n").unwrap();
fs::write(
en.join("SUMMARY.md"),
"# Summary\n\n* [Introduction](README.md)\n* [Chapter 1](ch1.md)\n",
)
.unwrap();
fs::write(en.join("ch1.md"), "# Chapter 1\nEnglish content\n").unwrap();
let ja = dir.join("ja");
fs::create_dir_all(&ja).unwrap();
fs::write(ja.join("README.md"), "# Japanese Intro\nようこそ\n").unwrap();
fs::write(
ja.join("SUMMARY.md"),
"# Summary\n\n* [Introduction](README.md)\n* [Chapter 1](ch1.md)\n",
)
.unwrap();
fs::write(ja.join("ch1.md"), "# Chapter 1\n日本語コンテンツ\n").unwrap();
}
#[test]
fn test_multilang_build() {
let temp = tempdir().unwrap();
let source = temp.path().join("book");
let output = temp.path().join("output");
fs::create_dir_all(&source).unwrap();
create_multilang_book(&source);
let status = Command::new(guidebook_bin())
.arg("build")
.arg(source.to_str().unwrap())
.arg("-o")
.arg(output.to_str().unwrap())
.status()
.expect("Failed to execute guidebook build");
assert!(status.success(), "Multi-language build should succeed");
let index = output.join("index.html");
assert!(
index.exists(),
"Root index.html should be generated for language selection"
);
let en_index = output.join("en/index.html");
assert!(en_index.exists(), "en/index.html should be generated");
let en_content = fs::read_to_string(&en_index).unwrap();
assert!(
en_content.contains("English Intro") || en_content.contains("Welcome"),
"English index should contain English content"
);
let ja_index = output.join("ja/index.html");
assert!(ja_index.exists(), "ja/index.html should be generated");
let ja_content = fs::read_to_string(&ja_index).unwrap();
assert!(
ja_content.contains("Japanese Intro") || ja_content.contains("ようこそ"),
"Japanese index should contain Japanese content"
);
let en_ch1 = output.join("en/ch1.html");
assert!(en_ch1.exists(), "en/ch1.html should be generated");
let ja_ch1 = output.join("ja/ch1.html");
assert!(ja_ch1.exists(), "ja/ch1.html should be generated");
}