197 lines
5.4 KiB
Rust
197 lines
5.4 KiB
Rust
//! A reply's images and links as `deliver` posts them, against a fake `loopd` whose `TurnDone`
|
|
//! says whether the session has read untrusted content; and `answer_posts` on replies long enough
|
|
//! to be split (M5b spec, section 9). Do not edit.
|
|
|
|
#[path = "support/fake_loop.rs"]
|
|
mod fake_loop;
|
|
#[path = "support/tmp.rs"]
|
|
mod tmp;
|
|
|
|
use std::sync::Mutex;
|
|
use std::time::Duration;
|
|
|
|
use bxcore::{Envelope, Message, PROTOCOL_VERSION, SessionId, TurnDone, Usage};
|
|
use fake_loop::{Reply, done, serve_loop};
|
|
use gatewayd::deliver::{EMPTY_ANSWER, MAX_POST, Notice, Poster, answer_posts, deliver};
|
|
use gatewayd::links::NOTICE;
|
|
use gatewayd::mm::MmError;
|
|
use gatewayd::sessions::{Batch, Thread};
|
|
use tmp::TempDir;
|
|
|
|
const DM: &str = "d0000000000000000000000000";
|
|
const ROOT: &str = "r0000000000000000000000000";
|
|
|
|
#[derive(Default)]
|
|
struct Record {
|
|
posts: Mutex<Vec<String>>,
|
|
}
|
|
|
|
impl Poster for Record {
|
|
fn post(&self, channel: &str, root: &str, text: &str) -> Result<(), MmError> {
|
|
assert_eq!((channel, root), (DM, ROOT), "every post in the thread");
|
|
self.posts.lock().unwrap().push(text.to_string());
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// The frame that ends a turn of a session that has read untrusted content.
|
|
fn done_untrusted(content: &str) -> Reply {
|
|
let msg = Message::TurnDone(TurnDone {
|
|
content: content.to_string(),
|
|
usage: Usage {
|
|
cache_n: 0,
|
|
prompt_n: 1,
|
|
predicted_n: 1,
|
|
reasoning_tokens: 0,
|
|
thinking_capped: false,
|
|
},
|
|
untrusted: true,
|
|
});
|
|
Reply::Frame(Envelope {
|
|
v: PROTOCOL_VERSION,
|
|
id: 1,
|
|
r#final: true,
|
|
msg,
|
|
})
|
|
}
|
|
|
|
/// One turn against a fake `loopd` that ends it with `reply`; the posts, in order.
|
|
fn posts(tag: &str, reply: fn(&str) -> Reply, content: &'static str) -> Vec<String> {
|
|
let dir = TempDir::new(tag);
|
|
let socket = dir.path().join("loop.sock");
|
|
let turns = serve_loop(&socket, move |_, _| vec![reply(content)]);
|
|
let batch = Batch {
|
|
session: SessionId::new(&format!("mm-{ROOT}")).unwrap(),
|
|
thread: Thread {
|
|
channel: DM.to_string(),
|
|
root: ROOT.to_string(),
|
|
},
|
|
resume: false,
|
|
text: "hi".to_string(),
|
|
};
|
|
let never = |_: u64| false;
|
|
let notice = Notice {
|
|
posted: &never,
|
|
wait: Duration::ZERO,
|
|
};
|
|
let record = Record::default();
|
|
let log = Mutex::new(Vec::new());
|
|
deliver(
|
|
&record,
|
|
&socket,
|
|
&batch,
|
|
&|line| log.lock().unwrap().push(line.to_string()),
|
|
¬ice,
|
|
);
|
|
turns.recv_timeout(Duration::from_secs(5)).unwrap();
|
|
assert!(log.lock().unwrap().is_empty(), "{:?}", log.lock().unwrap());
|
|
record.posts.into_inner().unwrap()
|
|
}
|
|
|
|
const LINKED: &str = "See [the docs](https://docs.example/p) and https://b.example/x.";
|
|
|
|
#[test]
|
|
fn an_untrusted_reply_posts_its_links_as_code_and_ends_with_the_notice() {
|
|
assert_eq!(
|
|
posts("links-untrusted", done_untrusted, LINKED),
|
|
[format!(
|
|
"See `[the docs](https://docs.example/p)` and `https://b.example/x`.\n\n{NOTICE}"
|
|
)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_clean_reply_keeps_its_links() {
|
|
assert_eq!(posts("links-clean", done, LINKED), [LINKED]);
|
|
}
|
|
|
|
#[test]
|
|
fn an_image_that_carries_data_is_posted_as_text_in_a_clean_reply() {
|
|
assert_eq!(
|
|
posts(
|
|
"links-image",
|
|
done,
|
|
"Summary done. "
|
|
),
|
|
["Summary done. ``"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_untrusted_reply_with_only_an_image_has_no_notice() {
|
|
assert_eq!(
|
|
posts(
|
|
"links-only-image",
|
|
done_untrusted,
|
|
""
|
|
),
|
|
["``"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn code_in_an_untrusted_reply_is_left_and_needs_no_notice() {
|
|
let text = "Run `curl https://example.com/x`:\n```\nhttps://example.com/y\n```";
|
|
assert_eq!(posts("links-code", done_untrusted, text), [text]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_long_untrusted_reply_has_one_notice_at_its_end() {
|
|
let content = format!(
|
|
"https://a.example/1\n{}\nhttps://b.example/2",
|
|
"x".repeat(MAX_POST - 10)
|
|
);
|
|
let posts = answer_posts(&content, true);
|
|
assert_eq!(
|
|
posts,
|
|
[
|
|
"`https://a.example/1`".to_string(),
|
|
"x".repeat(MAX_POST - 10),
|
|
format!("`https://b.example/2`\n\n{NOTICE}"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_part_that_starts_inside_a_fence_is_disarmed_again() {
|
|
let content = format!(
|
|
"```\n{}\n{}\n\n```",
|
|
"y".repeat(10_000),
|
|
"z".repeat(10_000)
|
|
);
|
|
assert_eq!(
|
|
answer_posts(&content, false),
|
|
[
|
|
format!("```\n{}", "y".repeat(10_000)),
|
|
format!(
|
|
"{}\n````````\n```",
|
|
"z".repeat(10_000)
|
|
),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_link_found_only_after_the_split_still_brings_the_notice() {
|
|
let content = format!(
|
|
"```\n{}\n{}\nhttps://e.example/a\n```",
|
|
"y".repeat(10_000),
|
|
"z".repeat(10_000)
|
|
);
|
|
assert_eq!(
|
|
answer_posts(&content, true),
|
|
[
|
|
format!("```\n{}", "y".repeat(10_000)),
|
|
format!(
|
|
"{}\n````https://e.example/a````\n```\n\n{NOTICE}",
|
|
"z".repeat(10_000)
|
|
),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_answer_is_posted_as_before() {
|
|
assert_eq!(answer_posts(" \n", true), [EMPTY_ANSWER]);
|
|
}
|