chadtmiller.com/atproto
atproto's primitives in Rust, small and free of I/O
atproto 0.1.1 Latest 1 package
0
0
//! Values checked against lexicons, against the verdicts of atproto's
//! reference (tests/reference/lexicon.json, from lexicon.mjs), and the
//! string formats against atproto's interop vectors (tests/interop/syntax).
use atproto::lexicon::{
Lexicons, is_at_identifier, is_at_uri, is_cid_string, is_datetime, is_format, is_language,
is_uri,
};
use serde_json::{Value, json};
/// The cases this departs from the reference on, and its own verdict on
/// each: the module's doc comment says why.
const DEPARTURES: [(&str, bool); 7] = [
// Permissioned spaces' URIs, which the reference does not know yet.
("at-uri-valid-space-ref", true),
("at-uri-valid-space-ref-self", true),
("at-uri-valid-space-record", true),
// A blob's accept and maxSize, which the reference does not check.
("blob-data-invalid-unaccepted-mime", false),
("blob-data-invalid-exceeds-maxSize", false),
("e2e-profile-invalid-avatar-wrong-mime", false),
// A version 0 CID, which the cid format's interop vectors refuse.
("cid-invalid-qmb-prefix", false),
];
fn vectors() -> Vec<Value> {
let path = format!(
"{}/tests/reference/lexicon.json",
env!("CARGO_MANIFEST_DIR")
);
let file: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
file["vectors"].as_array().unwrap().clone()
}
fn check(v: &Value) -> Result<(), atproto::lexicon::LexiconError> {
let lexicons = Lexicons::new(v["lexicons"].as_array().unwrap().iter().cloned());
let reference = v["ref"].as_str().unwrap();
match v["kind"].as_str().unwrap() {
"params" => lexicons.validate_params(reference, v["value"].as_object().unwrap()),
"input" => lexicons.validate_input(reference, &v["value"]),
_ => lexicons.validate(reference, &v["value"]),
}
}
#[test]
fn every_vector() {
let vectors = vectors();
assert!(vectors.len() > 200);
let mut departed = 0;
for v in &vectors {
let name = v["name"].as_str().unwrap();
let ours = check(v);
let want = match DEPARTURES.iter().find(|(n, _)| *n == name) {
Some((_, verdict)) => {
departed += 1;
assert_ne!(*verdict, v["valid"].as_bool().unwrap(), "{name} departs");
*verdict
}
None => v["valid"].as_bool().unwrap(),
};
assert_eq!(
ours.is_ok(),
want,
"{name}: {ours:?}, the reference: {}",
v["error"]
);
}
assert_eq!(departed, DEPARTURES.len());
}
/// The lines of an interop vector file, less comments and blank lines.
fn lines(name: &str) -> Vec<String> {
let path = format!("{}/tests/interop/syntax/{name}", env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(path)
.unwrap()
.lines()
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(String::from)
.collect()
}
fn interop(kind: &str, is: fn(&str) -> bool) {
for valid in lines(&format!("{kind}_syntax_valid.txt")) {
assert!(is(&valid), "{kind} should be valid: {valid:?}");
}
for invalid in lines(&format!("{kind}_syntax_invalid.txt")) {
assert!(!is(&invalid), "{kind} should be invalid: {invalid:?}");
}
}
#[test]
fn formats_against_interop() {
interop("aturi", is_at_uri);
interop("atidentifier", is_at_identifier);
interop("cid", is_cid_string);
interop("datetime", is_datetime);
interop("language", is_language);
interop("uri", is_uri);
for invalid in lines("datetime_parse_invalid.txt") {
assert!(!is_datetime(&invalid), "{invalid:?}");
}
}
#[test]
fn formats() {
for (format, s) in [
("did", "did:plc:abc"),
("handle", "a.example.com"),
("nsid", "com.example.role"),
("tid", "3jzfcijpj2z2a"),
("record-key", "self"),
("a-format-to-come", " anything "),
("at-uri", "at://did:plc:abc/com.example.role/x#/a%C3%A9"),
("datetime", "1985-04-31T23:20:50Z"),
("datetime", "9999-12-31T23:59:59+01:00"),
] {
assert!(is_format(format, s), "{format} {s}");
}
for (format, s) in [
("at-uri", "at://did:plc:abc/com.example.role/x#/%FF"),
("at-uri", "at://did:plc:abc/com.example.role/x#/%G1"),
("at-uri", "at://did:plc:abc/com.example.role/x?q"),
("at-uri", "at://alice.example.com/space/com.example.space/x"),
(
"at-uri",
"at://did:plc:abc/space/com.example.space/x/did:plc:w/nsid/r",
),
("datetime", "9999-12-31T23:59:59-01:00"),
("datetime", "1985-04-12T23:20:50.123+24:00"),
("datetime", "1985-04-12T23:20:50x"),
("datetime", "1985-04-12T23:20:50.Z"),
("datetime", "1985-04-12T23:20:50é"),
("language", "en-a"),
("language", "de-1901-1901"),
("language", "x"),
] {
assert!(!is_format(format, s), "{format} {s}");
}
}
fn host() -> Lexicons {
Lexicons::new([
json!({"lexicon": 1, "id": "test.call", "defs": {
"main": {"type": "query", "parameters": {"type": "params", "required": ["n"], "properties": {
"n": {"type": "integer"},
"b": {"type": "boolean"},
"s": {"type": "string", "default": "x"},
"tags": {"type": "array", "items": {"type": "integer"}}
}}}
}}),
json!({"lexicon": 1, "id": "test.bare", "defs": {"main": {"type": "procedure"}}}),
json!({"lexicon": 1, "defs": {}}),
])
}
#[test]
fn query_strings_are_read_as_their_types() {
let lex = host();
let pairs = |p: &[(&str, &str)]| -> Vec<(String, String)> {
p.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
let params = lex.query_params(
"test.call",
&pairs(&[
("n", "3"),
("b", "true"),
("tags", "1"),
("tags", "2"),
("other", "x"),
]),
);
assert_eq!(
Value::Object(params.clone()),
json!({"n": 3, "b": true, "tags": [1, 2]})
);
assert!(lex.validate_params("test.call", ¶ms).is_ok());
let params = lex.query_params("test.call", &pairs(&[("n", "three")]));
let err = lex.validate_params("test.call", ¶ms).unwrap_err();
assert_eq!(err.to_string(), "params.n must be an integer");
let err = lex
.validate_params("test.call", &Default::default())
.unwrap_err();
assert_eq!(err.to_string(), "params must have the property \"n\"");
assert!(
lex.query_params("test.none", &pairs(&[("n", "1")]))
.is_empty()
);
assert!(
lex.validate_params("test.bare", &Default::default())
.is_ok()
);
assert!(lex.validate_input("test.bare", &json!({})).is_ok());
let err = lex.validate_input("test.call", &json!({})).unwrap_err();
assert_eq!(err.to_string(), "input test.call is not a procedure");
let err = lex.validate("test.none", &json!({})).unwrap_err();
assert_eq!(err.to_string(), "record no lexicon test.none");
assert!(lex.def("test.call#main").is_some());
}
#[test]
fn errors_say_where() {
let v = vectors();
let path = v
.iter()
.find(|v| v["name"] == "own-invalid-input-path")
.unwrap();
let err = check(path).unwrap_err();
assert_eq!(err.path, "input.roles[2].role");
assert_eq!(err.message, "must not be longer than 3 bytes");
}
#[test]
fn edges() {
let lex = Lexicons::new([json!({"lexicon": 1, "id": "test.edge", "defs": {
"main": {"type": "record", "key": "tid", "record": {"type": "object",
"required": ["flag", "inner"],
"properties": {
"flag": {"type": "boolean", "default": true},
"inner": {"type": "ref", "ref": "#inner"},
"list": {"type": "array", "items": {"type": "unknown"}},
"link": {"type": "cid-link"},
"loose": {"type": "unknown"}
}}},
"inner": {"type": "object"}
}})]);
let ok = |v: Value| lex.validate("test.edge", &v);
assert!(ok(json!({"inner": {}})).is_ok());
let missing = ok(json!({})).unwrap_err();
assert_eq!(
missing.to_string(),
"record must have the property \"inner\""
);
let inner = ok(json!({"inner": 1})).unwrap_err();
assert_eq!(inner.to_string(), "record.inner must be an object");
let list = ok(json!({"inner": {}, "list": {}})).unwrap_err();
assert_eq!(list.to_string(), "record.list must be an array");
let bytes = ok(json!({"inner": {}, "loose": {"a": [{"$bytes": "!"}]}})).unwrap_err();
assert_eq!(
bytes.to_string(),
"record.loose.a[0] must hold base64 in $bytes"
);
let cid = |bytes: &[u8]| {
format!(
"b{}",
atproto::base32::encode(atproto::base32::LOWER, bytes)
)
};
let raw = atproto::Cid::of_raw(b"x").to_bytes();
let z = format!("z{}", bs58::encode(&raw).into_string());
for (link, valid) in [
(z, true),
(cid(&raw), true),
(cid(&[2, 0x55, 0x12, 0]), false),
(cid(&[0x80; 10]), false),
("xyz".to_string(), false),
] {
let v = ok(json!({"inner": {}, "link": {"$link": link}}));
assert_eq!(v.is_ok(), valid, "{link}");
}
let params = Lexicons::new([json!({"lexicon": 1, "id": "test.q", "defs": {"main": {
"type": "query", "parameters": {"type": "params", "properties": {"s": {"type": "string"}}}
}}})]);
let given = params.query_params("test.q", &[("s".into(), "1".into())]);
assert_eq!(Value::Object(given), json!({"s": "1"}));
assert!(is_language("zh-yue-HK"));
}