use std::cell::{Cell, RefCell};
use std::time::Instant;
use clap::Parser;
use qq_cli::cli::args::Cli;
use qq_cli::core::error::Result;
use qq_cli::messaging::collection;
use qq_cli::messaging::session::Session;
use qq_cli::storage::config::Config;
use qq_cli::storage::references::References;
use qq_cli::storage::state::Instance;
use qq_cli::transport::client::{Action, Backend};
use serde_json::{Value, json};
struct Fake {
calls: RefCell<Vec<String>>,
identity_reads: Cell<usize>,
switch_after: Option<usize>,
wrong_group: bool,
}
impl Fake {
fn new() -> Self {
Self {
calls: RefCell::new(vec![]),
identity_reads: Cell::new(0),
switch_after: None,
wrong_group: false,
}
}
}
impl Backend for Fake {
fn call(&self, action: Action, _: &Value, _: Instant) -> Result<Value> {
self.calls.borrow_mut().push(action.name().into());
Ok(match action {
Action::VersionInfo => json!({"app_version":"4.18.38"}),
Action::LoginInfo => {
self.identity_reads.set(self.identity_reads.get() + 1);
let switched = self
.switch_after
.is_some_and(|after| self.identity_reads.get() > after);
json!({"user_id":if switched {"10002"} else {"10001"},"nickname":"Synthetic"})
}
Action::Status => json!({"online":true,"good":true}),
Action::FriendList => json!([
{"user_id":"10002","nickname":"Sample Two"},
{"user_id":"10001","nickname":"Sample One"},
{"user_id":"10003","nickname":"Other"}
]),
Action::GroupList => json!([{ "group_id":"20001","group_name":"Example Group" }]),
Action::GroupInfo => {
json!({"group_id":if self.wrong_group {"20002"}else{"20001"},"group_name":"Example Group"})
}
Action::GroupMembers => {
json!([{"user_id":"10002","role":"member","nickname":"Sample"}])
}
Action::RecentContacts => {
json!([{ "peerUin":"20001","chatType":2,"msgTime":"1700000000","peerName":"Recent Example",
"lastestMsg":{"message":"synthetic-private-body"} }])
}
_ => panic!("unexpected action in read contract test"),
})
}
}
#[test]
fn public_read_schemas_validate_actual_gets_collections_and_pagination_constraints() {
let temp = tempfile::tempdir().unwrap();
let refs = References::with_key(temp.path().join("refs"), [84; 32]);
let backend = Fake::new();
let config = Config::default();
let instance = instance();
let session = Session {
backend: &backend,
config: &config,
instance: &instance,
expected_version: "4.18.38",
};
for (name, args) in [
("contact.list", vec!["contact", "list", "--limit", "1"]),
("contact.get", vec!["contact", "get", "10002"]),
("group.list", vec!["group", "list"]),
("group.get", vec!["group", "get", "20001"]),
("group.members", vec!["group", "members", "20001"]),
("conversation.list", vec!["conversation", "list"]),
(
"conversation.get",
vec!["conversation", "get", "group:20001"],
),
] {
let cli = Cli::try_parse_from(std::iter::once("qq-cli").chain(args)).unwrap();
cli.validate().unwrap();
let output = if let Some(request) = collection::request(&cli.command) {
collection::execute(request, &session, &refs).unwrap()
} else {
qq_cli::app::execute_read(&cli.command, &session).unwrap()
};
let value = serde_json::to_value(output).unwrap();
let description = qq_cli::cli::registry::get(name).unwrap();
let validator = jsonschema::validator_for(&description["responseSchema"]).unwrap();
assert!(validator.is_valid(&value), "{name}: {value}");
let mut invalid = value.clone();
invalid["meta"]["scope"]["account"] = json!(10001);
assert!(!validator.is_valid(&invalid));
let mut invalid = value.clone();
if invalid["data"].is_array() {
invalid["data"][0]["private_url"] = json!("hidden");
} else {
invalid["data"]["private_url"] = json!("hidden");
}
assert!(!validator.is_valid(&invalid));
if name == "contact.list" {
let mut invalid = value.clone();
invalid["pageInfo"]["nextCursor"] = json!(null);
assert!(!validator.is_valid(&invalid));
}
let error = serde_json::to_value(qq_cli::core::output::Envelope::failure(
qq_cli::core::error::AppError::invalid(),
))
.unwrap();
assert!(validator.is_valid(&error));
}
}
#[test]
fn group_get_rejects_wrong_target_and_recent_activity_rejects_millisecond_timestamps() {
let mut backend = Fake::new();
backend.wrong_group = true;
let config = Config::default();
let instance = instance();
let session = Session {
backend: &backend,
config: &config,
instance: &instance,
expected_version: "4.18.38",
};
let cli = Cli::try_parse_from(["qq-cli", "group", "get", "20001"]).unwrap();
assert_eq!(
qq_cli::app::execute_read(&cli.command, &session)
.err()
.unwrap()
.code,
"BACKEND_PROTOCOL"
);
assert!(
qq_cli::messaging::read::recent(
&json!({"peerUin":"20001","chatType":2,"msgTime":"1700000000000"})
)
.is_err()
);
}
fn instance() -> Instance {
Instance {
id: "instance-example".into(),
installation_id: "install-example".into(),
endpoint: "http://127.0.0.1:16000/".into(),
ws_endpoint: None,
credential_key: "synthetic-reference".into(),
account: Some("10001".into()),
run_id: "run-example".into(),
state: "ready".into(),
process_id: None,
process_started: None,
}
}
#[test]
fn a_normal_full_page_succeeds_and_cursor_restores_filter_without_body_storage() {
let temp = tempfile::tempdir().unwrap();
let references = References::with_key(temp.path().join("references"), [42; 32]);
let backend = Fake::new();
let config = Config::default();
let instance = instance();
let session = Session {
backend: &backend,
config: &config,
instance: &instance,
expected_version: "v4.18.38",
};
let command = Cli::try_parse_from([
"qq-cli", "contact", "list", "--query", "Sample", "--limit", "1",
])
.unwrap();
let page = collection::execute(
collection::request(&command.command).unwrap(),
&session,
&references,
)
.unwrap();
assert_eq!(page.exit_code(), 0);
let page = serde_json::to_value(page).unwrap();
assert_eq!(page["data"][0]["userId"], "10001");
assert_eq!(page["pageInfo"]["complete"], false);
let cursor = page["pageInfo"]["nextCursor"].as_str().unwrap();
assert!(cursor.len() < 80, "opaque handles should be compact");
let command = Cli::try_parse_from([
"qq-cli", "contact", "list", "--cursor", cursor, "--limit", "1",
])
.unwrap();
assert_eq!(
collection::inferred_instance(&command.command, &references)
.unwrap()
.as_deref(),
Some("instance-example")
);
let next = collection::execute(
collection::request(&command.command).unwrap(),
&session,
&references,
)
.unwrap();
let next = serde_json::to_value(next).unwrap();
assert_eq!(next["data"][0]["userId"], "10002");
assert_eq!(next["pageInfo"]["complete"], true);
for file in std::fs::read_dir(temp.path().join("references")).unwrap() {
let metadata = std::fs::read_to_string(file.unwrap().path()).unwrap();
assert!(!metadata.contains("Sample One"));
assert!(!metadata.contains("Sample Two"));
}
}
#[test]
fn recent_filter_does_not_expand_into_contacts_or_groups() {
let temp = tempfile::tempdir().unwrap();
let references = References::with_key(temp.path().join("references"), [42; 32]);
let backend = Fake::new();
let config = Config::default();
let instance = instance();
let session = Session {
backend: &backend,
config: &config,
instance: &instance,
expected_version: "4.18.38",
};
let command =
Cli::try_parse_from(["qq-cli", "conversation", "list", "--query", "Sample"]).unwrap();
let result = collection::execute(
collection::request(&command.command).unwrap(),
&session,
&references,
)
.unwrap();
let result = serde_json::to_value(result).unwrap();
assert!(result["data"].as_array().unwrap().is_empty());
assert_eq!(result["pageInfo"]["coverage"], "recent");
assert!(
!backend
.calls
.borrow()
.iter()
.any(|name| matches!(name.as_str(), "get_friend_list" | "get_group_list"))
);
assert!(!result.to_string().contains("synthetic-private-body"));
}
#[test]
fn policy_wrong_account_and_mid_read_account_changes_fail_closed() {
let temp = tempfile::tempdir().unwrap();
let references = References::with_key(temp.path().join("references"), [42; 32]);
let command = Cli::try_parse_from(["qq-cli", "contact", "list"]).unwrap();
let mut config = Config::default();
config.permissions.read = false;
let instance = instance();
let backend = Fake::new();
let session = Session {
backend: &backend,
config: &config,
instance: &instance,
expected_version: "4.18.38",
};
assert_eq!(
collection::execute(
collection::request(&command.command).unwrap(),
&session,
&references
)
.err()
.unwrap()
.code,
"PERMISSION_DENIED"
);
assert!(backend.calls.borrow().is_empty());
config.permissions.read = true;
for switch_after in [0, 1] {
let mut backend = Fake::new();
backend.switch_after = Some(switch_after);
let session = Session {
backend: &backend,
config: &config,
instance: &instance,
expected_version: "4.18.38",
};
let error = collection::execute(
collection::request(&command.command).unwrap(),
&session,
&references,
)
.err()
.unwrap();
assert_eq!(error.code, "ACCOUNT_CHANGED");
let reads = backend
.calls
.borrow()
.iter()
.filter(|action| action.as_str() == "get_friend_list")
.count();
assert_eq!(reads, switch_after);
}
}
#[test]
fn references_detect_metadata_tampering_and_cross_state_handles() {
let temp = tempfile::tempdir().unwrap();
let directory = temp.path().join("references");
let references = References::with_key(directory.clone(), [42; 32]);
let token = references
.issue("m1", &json!({"account":"10001","id":"-123"}))
.unwrap();
assert_eq!(references.get::<Value>("m1", &token).unwrap()["id"], "-123");
let foreign = References::with_key(directory.clone(), [43; 32]);
assert!(foreign.get::<Value>("m1", &token).is_err());
let path = std::fs::read_dir(directory)
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
std::fs::write(path, b"{\"account\":\"10002\",\"id\":\"-123\"}").unwrap();
assert!(references.get::<Value>("m1", &token).is_err());
assert!(references.get::<Value>("m1", "m1.../escape").is_err());
}
#[test]
fn collection_cursor_keeps_pre_migration_canonical_snapshot_identity() {
use sha2::Digest;
let temp = tempfile::tempdir().unwrap();
let refs = References::with_key(temp.path().join("refs"), [84; 32]);
let backend = Fake::new();
let config = Config::default();
let instance = instance();
let session = Session {
backend: &backend,
config: &config,
instance: &instance,
expected_version: "4.18.38",
};
let cli = Cli::try_parse_from(["qq-cli", "contact", "list", "--limit", "1"]).unwrap();
let value = serde_json::to_value(
collection::execute(collection::request(&cli.command).unwrap(), &session, &refs).unwrap(),
)
.unwrap();
let mut old: Value = refs
.get("c1", value["pageInfo"]["nextCursor"].as_str().unwrap())
.unwrap();
let old_rows = json!([
{"userId":"10001","conversation":"private:10001","nickname":"Sample One"},
{"userId":"10002","conversation":"private:10002","nickname":"Sample Two"},
{"userId":"10003","conversation":"private:10003","nickname":"Other"}
]);
let original = format!(
"{:x}",
sha2::Sha256::digest(serde_json::to_vec(&old_rows).unwrap())
);
assert_eq!(old["snapshot"], original);
old["snapshot"] = json!(original);
let token = refs.issue("c1", &old).unwrap();
let cli = Cli::try_parse_from(["qq-cli", "contact", "list", "--cursor", &token]).unwrap();
let next = serde_json::to_value(
collection::execute(collection::request(&cli.command).unwrap(), &session, &refs).unwrap(),
)
.unwrap();
assert_eq!(next["data"][0]["userId"], "10002");
}