//! `atgc string` end to end, against the mock PDSes and appview. #![cfg(test)] mod support; use support::{ALICE, BOB, Scenario}; const STRING_NSID: &str = "org.tangled.snippet.string"; /// A CID the mock serves from an account's PDS. It need not hash the bytes: /// `getBlob` is addressed by the CID the record names. const BLOB_CID: &str = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; /// A current string record as tangled.org writes one, and the blob it names. fn plant_string( world: &Scenario, did: &str, rkey: &str, name: &str, description: &str, contents: &str, created_at: &str, ) { world.with(|w| { w.blobs .insert(BLOB_CID.to_string(), contents.as_bytes().to_vec()); w.blob_owner.insert(BLOB_CID.to_string(), did.to_string()); w.plant( did, STRING_NSID, rkey, serde_json::json!({ "$type": STRING_NSID, "snippet": { "$type": "org.tangled.snippet.string#textFile", "name": name, "content": { "$type": "blob", "ref": { "$link": BLOB_CID }, "mimeType": "text/plain", "size": contents.len(), }, }, "description": description, "createdAt": created_at, }), ); }); } /// Create, list, read back and delete one string, each step seeing the last. #[test] fn a_string_round_trips_through_create_list_view_and_delete() { let world = Scenario::new("string-round-trip"); let file = world.scratch("paste").join("notes.md"); let contents = "# notes\n\n indented, and no trailing newline"; std::fs::write(&file, contents).unwrap(); let created = world .run(&[ "string", "create", file.to_str().unwrap(), "-d", "a checklist", "--json", ]) .success() .json(); assert_eq!(created["filename"], "notes.md", "{created:#}"); assert_eq!(created["description"], "a checklist"); assert_eq!(created["bytes"], serde_json::json!(contents.len())); let uri = created["uri"].as_str().expect("an at-uri").to_string(); let rkey = uri.rsplit('/').next().unwrap().to_string(); assert_eq!(uri, format!("at://{ALICE}/{STRING_NSID}/{rkey}")); assert!( created["url"] .as_str() .unwrap() .ends_with(&format!("/strings/alice.test/{rkey}")), "{created:#}" ); // The blob is uploaded first, then the record that names it. let labels = world.with(|w| w.labels()); let upload = labels .iter() .position(|l| l.ends_with("com.atproto.repo.uploadBlob")) .expect("an uploadBlob"); let create = labels .iter() .position(|l| l.ends_with("com.atproto.repo.createRecord")) .expect("a createRecord"); assert!(upload < create, "{labels:?}"); // The record is what tangled.org writes, field for field. let stored = world.records(ALICE, STRING_NSID); assert_eq!(stored.len(), 1); let value = &stored[0].1; assert_eq!( value.as_object().expect("a record object").len(), 4, "no field beyond the lexicon's: {value:#}" ); assert_eq!(value["$type"], STRING_NSID); assert_eq!(value["description"], "a checklist"); assert!(value["createdAt"].as_str().is_some(), "{value:#}"); assert_eq!(value["snippet"].as_object().unwrap().len(), 3, "{value:#}"); assert_eq!( value["snippet"]["$type"], "org.tangled.snippet.string#textFile" ); assert_eq!(value["snippet"]["name"], "notes.md"); assert_eq!( value["snippet"]["content"].as_object().unwrap().len(), 4, "{value:#}" ); assert_eq!(value["snippet"]["content"]["$type"], "blob"); assert_eq!(value["snippet"]["content"]["mimeType"], "text/plain"); assert_eq!( value["snippet"]["content"]["size"], serde_json::json!(contents.len()) ); let cid = value["snippet"]["content"]["ref"]["$link"] .as_str() .expect("the blob reference"); assert_eq!(world.blob(cid), contents.as_bytes()); let listed = world.run(&["string", "list", "--json"]).success().json(); assert_eq!(listed.as_array().unwrap().len(), 1, "{listed:#}"); assert_eq!(listed[0]["rkey"], rkey.as_str()); assert_eq!(listed[0]["filename"], "notes.md"); // There is no page per account, and `/strings/` is a 404, so the // listing prints no link at all. let text = world.run(&["string", "list"]).success(); assert!(text.stdout.contains("notes.md"), "{}", text.stdout); assert!(!text.stdout.contains("view:"), "{}", text.stdout); // Byte for byte: --raw adds no newline and trims nothing. let raw = world.run(&["string", "view", &rkey, "--raw"]).success(); assert_eq!(raw.stdout, contents); let deleted = world .run(&["string", "delete", &rkey, "--json"]) .success() .json(); assert_eq!(deleted["deleted"], true, "{deleted:#}"); assert_eq!(deleted["uri"], format!("at://{ALICE}/{STRING_NSID}/{rkey}")); assert!(world.records(ALICE, STRING_NSID).is_empty()); let after = world.run(&["string", "list", "--json"]).success().json(); assert_eq!(after, serde_json::json!([])); } /// Somebody else's string, by the URL tangled.org shows it at and by at-uri. #[test] fn another_accounts_string_is_read_by_its_url() { let world = Scenario::new("string-view-url"); let contents = "line one\n\x1b]0;owned\x07line two\n"; plant_string( &world, BOB, "3mysw3uwzo52b", "hello.sh", "a greeting", contents, "2026-10-20T12:00:00Z", ); let url = "https://tangled.org/strings/bob.test/3mysw3uwzo52b"; let shown = world.run(&["string", "view", url]).success(); assert!(shown.stdout.starts_with("hello.sh\n"), "{}", shown.stdout); assert!(shown.stdout.contains("a greeting"), "{}", shown.stdout); assert!( shown .stdout .contains(&format!("at://{BOB}/{STRING_NSID}/3mysw3uwzo52b")), "{}", shown.stdout ); // The author's escape sequence is drawn as nothing in the text view. assert!( shown.stdout.contains("line one\n]0;ownedline two"), "{}", shown.stdout ); assert!(!shown.stdout.contains('\x1b'), "{:?}", shown.stdout); // --raw into a pipe is the stored bytes, escape and all. let raw = world .run(&["string", "view", &format!("{url}/raw"), "--raw"]) .success(); assert_eq!(raw.stdout, contents); // An at-uri naming the current collection reads the same record. let at_uri = format!("at://{BOB}/{STRING_NSID}/3mysw3uwzo52b"); let raw = world.run(&["string", "view", &at_uri, "--raw"]).success(); assert_eq!(raw.stdout, contents); } /// A file the lexicon would refuse is refused before anything is written. #[test] fn an_over_limit_string_is_refused_before_any_write() { let world = Scenario::new("string-over-limit"); let dir = world.scratch("paste"); // 141 graphemes: one over the lexicon's limit for a filename. let long = dir.join(format!("{}.txt", "a".repeat(137))); std::fs::write(&long, "contents").unwrap(); world .run(&["string", "create", long.to_str().unwrap()]) .refused_with(2, "filename"); // 257 graphemes: one over the description limit. let short = dir.join("ok.txt"); std::fs::write(&short, "contents").unwrap(); world .run(&[ "string", "create", short.to_str().unwrap(), "--description", &"d".repeat(257), ]) .refused_with(2, "description"); // 1,000,001 bytes: one over the blob the lexicon accepts. let large = dir.join("large.txt"); std::fs::write(&large, "a".repeat(1_000_001)).unwrap(); world .run(&["string", "create", large.to_str().unwrap()]) .refused_with(2, "1000000"); let empty = dir.join("empty.txt"); std::fs::write(&empty, "\n\n").unwrap(); world .run(&["string", "create", empty.to_str().unwrap()]) .refused_with(2, "empty"); world.with(|w| { assert!( w.calls_to("com.atproto.repo.uploadBlob").is_empty(), "a refused string must not upload a blob" ); assert!( w.calls_to("com.atproto.repo.createRecord").is_empty(), "a refused string must not reach the PDS" ); }); } /// A login without the new collection's scope is refused by name, before the /// blob upload. #[test] fn creating_without_the_string_scope_is_refused_before_the_upload() { let world = Scenario::new("string-scope"); // Everything except the collection this command writes. world.set_scope(ALICE, "atproto"); let file = world.scratch("paste").join("notes.md"); std::fs::write(&file, "contents").unwrap(); let run = world .run(&["string", "create", file.to_str().unwrap()]) .refused_with(4, "missing scope"); assert!( run.stderr.contains(STRING_NSID), "the refusal should name the scope that is missing:\n{}", run.stderr ); world.with(|w| { assert!( w.calls_to("com.atproto.repo.uploadBlob").is_empty(), "a blob was uploaded without the scope" ); assert!( w.calls_to("com.atproto.repo.createRecord").is_empty(), "a record was written without the scope" ); }); } /// Delete acts on the acting account's strings, and refuses anybody else's. #[test] fn deleting_another_accounts_string_is_refused() { let world = Scenario::new("string-delete-foreign"); plant_string( &world, BOB, "3mysw3uwzo52b", "hello.sh", "", "echo hi\n", "2026-10-20T12:00:00Z", ); world .run(&[ "string", "delete", "https://tangled.org/strings/bob.test/3mysw3uwzo52b", ]) .refused_with(4, "belongs to"); world .run(&[ "string", "delete", &format!("at://{BOB}/{STRING_NSID}/3mysw3uwzo52b"), ]) .refused_with(4, "belongs to"); let deletes = world.with(|w| w.calls_to("com.atproto.repo.deleteRecord").len()); assert_eq!( deletes, 0, "nothing may be sent for another account's record" ); assert_eq!(world.records(BOB, STRING_NSID).len(), 1); } /// A dry-run create uploads no blob and writes no record. #[test] fn a_dry_run_string_create_writes_nothing() { let world = Scenario::new("string-create-dry-run"); let file = world.scratch("paste").join("notes.md"); std::fs::write(&file, "contents").unwrap(); let report = world .run(&[ "string", "create", file.to_str().unwrap(), "--dry-run", "--json", ]) .success() .json(); assert_eq!(report["dry_run"], true, "{report:#}"); assert_eq!(report["filename"], "notes.md", "{report:#}"); assert!(world.records(ALICE, STRING_NSID).is_empty()); world.with(|w| { assert!( w.calls_to("com.atproto.repo.uploadBlob").is_empty(), "a dry run uploaded a blob: {:?}", w.labels() ); assert!( w.calls_to("com.atproto.repo.createRecord").is_empty(), "a dry run wrote a record: {:?}", w.labels() ); }); } /// A dry-run delete leaves the planted string where it was. #[test] fn a_dry_run_string_delete_leaves_the_record_in_place() { let world = Scenario::new("string-delete-dry-run"); plant_string( &world, ALICE, "3mysw3uwzo52b", "hello.sh", "a greeting", "echo hi\n", "2026-10-20T12:00:00Z", ); let report = world .run(&["string", "delete", "3mysw3uwzo52b", "--dry-run", "--json"]) .success() .json(); assert_eq!(report["dry_run"], true, "{report:#}"); assert_eq!(report["deleted"], false, "{report:#}"); assert_eq!(world.records(ALICE, STRING_NSID).len(), 1); world.with(|w| { assert!( w.calls_to("com.atproto.repo.deleteRecord").is_empty(), "a dry run deleted the record: {:?}", w.labels() ); }); }