Files
sausage/host/tests/it/chat.rs
T

419 lines
16 KiB
Rust

//! A fresh world's seeded init service serves chat and proxies the real harness API.
use sausage::{
cred::{
Caller, Cred,
items::{Item, Rule},
requests::GrantRequest,
},
harness::{
input::{self, Input},
run_module_with_input,
},
process::Runtime,
world::World,
};
use std::{path::Path, sync::Arc, time::Duration};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn request(port: u16, path: &str, body: Option<&str>) -> anyhow::Result<(u16, Vec<u8>)> {
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?;
let method = if body.is_some() { "POST" } else { "GET" };
let body = body.unwrap_or("");
stream.write_all(format!("{method} {path} HTTP/1.1\r\nHost: localhost:{port}\r\nOrigin: http://localhost:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()).as_bytes()).await?;
let mut bytes = Vec::new();
stream.read_to_end(&mut bytes).await?;
let end = bytes
.windows(4)
.position(|s| s == b"\r\n\r\n")
.ok_or_else(|| anyhow::anyhow!("Missing HTTP headers for {path}"))?
+ 4;
let header = std::str::from_utf8(&bytes[..end])?;
let status = header.split_whitespace().nth(1).unwrap().parse()?;
Ok((status, bytes[end..].to_vec()))
}
#[test]
fn empty_rule_summary() -> anyhow::Result<()> {
let item = serde_json::from_value(serde_json::json!({"name":"Example","rules":[],"transform_js":"","fields":[]}))?;
let summary = sausage::cred::summary::operation_summary(&sausage::cred::edits::Operation::CreateItem { config: item });
assert!(summary.contains("No access rules."));
assert!(!summary.contains("Rules: ."));
Ok(())
}
#[test]
fn page_rendering() -> anyhow::Result<()> {
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland/web");
let page = std::fs::read_to_string(source.join("index.html"))?;
let script = page
.split_once("<script>")
.unwrap()
.1
.split_once("</script>")
.unwrap()
.0;
let dir = tempfile::tempdir()?;
let extracted = dir.path().join("chat.js");
std::fs::write(&extracted, script)?;
let fixture = source.join("render.test.cjs");
for args in [
[Path::new("--check"), extracted.as_path()],
[fixture.as_path(), extracted.as_path()],
] {
let output = std::process::Command::new("node").args(args).output()?;
anyhow::ensure!(
output.status.success(),
"Page script: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn seeded_chat() -> anyhow::Result<()> {
seeded_chat_case(false).await
}
#[tokio::test(flavor = "current_thread")]
async fn shutdown_with_open_connection() -> anyhow::Result<()> {
seeded_chat_case(true).await
}
async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = crate::common::target_dir();
crate::common::cwd_init();
anyhow::ensure!(
std::process::Command::new("cargo")
.args([
"build",
"--release",
"--locked",
"-p",
"sausage-web",
"-p",
"sausage-init",
"--target",
"wasm32-wasip2",
"--target-dir"
])
.arg(&target)
.current_dir(source.join("userland"))
.status()?
.success(),
"Chat guest build failed"
);
let bundle = tempfile::tempdir()?;
let units = bundle.path().join("etc/systemd/system");
std::fs::create_dir_all(units.join("multi-user.target.wants"))?;
std::fs::copy(
source.join("userland/web/web.service"),
units.join("web.service"),
)?;
std::fs::write(units.join("multi-user.target.wants/web.service"), "")?;
std::fs::create_dir_all(bundle.path().join("bin"))?;
for binary in ["web", "init"] {
std::fs::copy(
target.join(format!("wasm32-wasip2/release/{binary}.wasm")),
bundle.path().join(format!("bin/{binary}.wasm")),
)?;
}
crate::common::copy_tree(
&source.join("userland/web"),
&bundle.path().join("src/sausage/userland/web"),
)?;
let www = bundle.path().join("srv/www");
std::fs::create_dir_all(&www)?;
std::fs::copy(
source.join("userland/web/index.html"),
www.join("index.html"),
)?;
let dir = tempfile::tempdir()?;
drop(World::open_with_userland(dir.path(), Some(bundle.path()))?);
let port = std::net::TcpListener::bind("127.0.0.1:0")?
.local_addr()?
.port();
let seeded = dir.path().join("world/etc/systemd/system/web.service");
let unit = std::fs::read_to_string(&seeded)?.replace(
"ExecStart=/bin/web.wasm",
&format!("ExecStart=/bin/web.wasm --listen 127.0.0.1:{port}"),
);
std::fs::write(&seeded, &unit)?;
let world = World::open_with_userland(dir.path(), Some(bundle.path()))?;
assert_eq!(
std::fs::read_to_string(&seeded)?,
unit,
"Reopen must not replace a world's unit"
);
assert!(
dir.path()
.join("world/src/sausage/userland/web/Cargo.toml")
.is_file()
);
let cred = Arc::new(Cred::open(&world.host_dir)?);
let caller = Caller {
program: "/bin/web.wasm".into(),
pid: 1,
conversations: vec!["1".into()],
};
let item = cred.create_item(
Item {
id: String::new(),
name: "Chat test credential".into(),
version: 0,
free_use: false,
rules: vec![Rule {
port: None,
scheme: "https".into(),
hosts: vec!["example.test".into()],
path_prefixes: vec!["/".into()],
methods: None,
}],
validate_js: None,
transform_js: String::new(),
fields: vec![],
},
&caller,
cred.unlocked()?,
)?;
let pending = cred.file_request(
&caller,
"chat-test",
GrantRequest {
items: vec![item.id],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
let api = Arc::new(sausage::api::Api::open(
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8080",
)?);
let (sender, receiver) = input::channel();
api.attach_harness(sender.clone());
let runtime = Runtime::new_with_hooks(
World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
},
cred.clone(),
sausage::process::default_script(),
Some(api),
)?;
let services = sausage::process::supervise(runtime.clone());
let (library, _) = include_str!("../../js/harness-api-test-bundle.js")
.split_once("// harness-api-test.ts")
.unwrap();
let script = format!(
r#"{library}
const chatModels=createModels(),chatFaux=fauxProvider({{tokensPerSecond:100000}});
chatModels.setProvider(chatFaux.provider);chatFaux.setResponses([fauxAssistantMessage('Chat test reply')]);
const chatRegistry=createRegistry(),chatStorage=await openStorage(globalThis.storagePath);
const chatHarness=await Harness.open(chatStorage,{{models:chatModels,registry:chatRegistry}},BACKGROUND_CONTEXT);
await chatHarness.root(BACKGROUND_CONTEXT,{{agent:{{model:{{provider:'faux',modelId:'faux-1'}}}}}});
const chatApi=harnessApi(chatHarness,chatModels,{{state:()=>({{}})}},BACKGROUND_CONTEXT);
chatHarness.resume();await serve(chatHarness,BACKGROUND_CONTEXT,undefined,chatApi);
await chatApi.close();await chatHarness.close(BACKGROUND_CONTEXT);
"#
);
let client = async {
let deadline = std::time::Instant::now() + Duration::from_secs(60);
let page = loop {
if let Ok((200, body)) = request(port, "/", None).await {
break body;
}
anyhow::ensure!(
std::time::Instant::now() < deadline,
"Init did not start chat"
);
tokio::task::yield_now().await;
};
assert!(String::from_utf8_lossy(&page).contains("Sausage chat"));
std::fs::write(dir.path().join("world/srv/www/index.html"), "Live edit")?;
assert_eq!(request(port, "/", None).await?.1, b"Live edit");
assert_eq!(request(port, "/missing", None).await?.0, 404);
let (status, ndjson) = request(port, "/api/cargo", Some("{}")).await?;
assert_eq!(status, 200);
assert!(std::str::from_utf8(&ndjson)?.lines().count() >= 2);
for line in std::str::from_utf8(&ndjson)?.lines() {
let _: serde_json::Value = serde_json::from_str(line)?;
}
let root = loop {
let (status, body) = request(port, "/api/harness/conversations", None).await?;
let value: serde_json::Value = serde_json::from_slice(&body)?;
if status == 200
&& let Some(id) = value["conversations"][0]["id"].as_u64()
{
break id;
}
anyhow::ensure!(
std::time::Instant::now() < deadline,
"Harness was not ready"
);
tokio::task::yield_now().await;
};
let submit = serde_json::json!({"content":"Chat test input","requestId":"chat-stable-id"})
.to_string();
for _ in 0..2 {
assert_eq!(
request(
port,
&format!("/api/harness/conversations/{root}/submit"),
Some(&submit)
)
.await?
.0,
202
);
}
loop {
let (status, body) = request(
port,
&format!("/api/harness/conversations/{root}/entries?after=0"),
None,
)
.await?;
assert_eq!(status, 200);
if String::from_utf8_lossy(&body).contains("Chat test reply") {
let value: serde_json::Value = serde_json::from_slice(&body)?;
assert_eq!(
value["entries"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["kind"] == "pi.user")
.count(),
1
);
break;
}
anyhow::ensure!(std::time::Instant::now() < deadline, "Reply did not appear");
tokio::task::yield_now().await;
}
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?;
stream.write_all(format!("GET /api/harness/conversations/{root}/stream?after=0 HTTP/1.1\r\nHost: localhost:{port}\r\nConnection: close\r\n\r\n").as_bytes()).await?;
let mut streamed = Vec::new();
tokio::time::timeout(Duration::from_secs(10), async {
loop {
let mut chunk = [0; 4096];
let count = stream.read(&mut chunk).await?;
anyhow::ensure!(count > 0, "Stream closed before replay");
streamed.extend_from_slice(&chunk[..count]);
if String::from_utf8_lossy(&streamed).contains("Chat test reply") {
break;
}
}
Ok::<_, anyhow::Error>(())
})
.await??;
assert!(String::from_utf8_lossy(&streamed).contains("application/x-ndjson"));
drop(stream);
let (status, body) = request(port, "/api/harness/credential-requests", None).await?;
assert_eq!(status, 200);
let value: serde_json::Value = serde_json::from_slice(&body)?;
assert!(
value["requests"].as_array().unwrap().iter().any(
|r| r["id"] == pending && r["approvalUrl"] == format!("/#approve/{pending}")
)
);
assert_eq!(request(port, "/api/unknown", None).await?.0, 404);
assert_eq!(request(port, "/api/register", None).await?.0, 404);
use sausage::cred::webauthn::Challenge;
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
use webauthn_rs::prelude::{CreationChallengeResponse, RequestChallengeResponse};
let origin = url::Url::parse(&format!("http://localhost:{port}"))?;
let mut authenticator = WebauthnAuthenticator::new(SoftPasskey::new(true));
let (status, start) = request(
port,
"/api/cred/passkeys/register/start",
Some(r#"{"label":"Mounted browser"}"#),
)
.await?;
assert_eq!(status, 200, "{}", String::from_utf8_lossy(&start));
let start: Challenge<CreationChallengeResponse> = serde_json::from_slice(&start)?;
let registration = authenticator.do_registration(origin.clone(), start.options)?;
let finish =
serde_json::json!({"challenge_id":start.id,"response":registration}).to_string();
assert_eq!(
request(port, "/api/cred/passkeys/register/finish", Some(&finish))
.await?
.0,
200
);
assert_eq!(
request(
port,
"/api/cred/passkeys/register/start",
Some(r#"{"label":"Ungated"}"#)
)
.await?
.0,
403
);
assert_eq!(request(port, &format!("/api/approve/{pending}"), None).await?.0, 404);
let choice = r#"{"decision":"approve","overrides":{}}"#;
assert_ne!(
request(
port,
&format!("/api/cred/requests/{pending}/decide"),
Some(choice)
)
.await?
.0,
200
);
let challenge: Challenge<RequestChallengeResponse> = serde_json::from_slice(
&request(
port,
&format!("/api/cred/requests/{pending}/challenge"),
Some(choice),
)
.await?
.1,
)?;
let assertion = authenticator.do_authentication(origin, challenge.options)?;
let decision = serde_json::json!({"challenge_id":challenge.id,"assertion":assertion,"decision":"approve","overrides":{}}).to_string();
assert_eq!(
request(
port,
&format!("/api/cred/requests/{pending}/decide"),
Some(&decision)
)
.await?
.0,
200
);
sender.send(Input::Stop)?;
Ok::<_, anyhow::Error>(())
};
let result = tokio::time::timeout(Duration::from_secs(90), async {
tokio::try_join!(
run_module_with_input(world, Arc::new(runtime.clone()), cred, receiver, &script),
client
)
})
.await;
let connection = if open_connection {
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?;
// Leave the request incomplete so the server must wait for more input.
stream
.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n")
.await?;
Some(stream)
} else {
None
};
let started = std::time::Instant::now();
services.shutdown().await;
let elapsed = started.elapsed();
drop(connection);
result??;
println!("Chat shutdown (open_connection={open_connection}): {elapsed:?}");
anyhow::ensure!(
elapsed < Duration::from_secs(3),
"Chat delayed shutdown: {elapsed:?}"
);
Ok(())
}