//! One product per machine: the app's accounts are the CLI's credential profiles (~/.storebound/credentials), keychain //! accounts of earlier versions move there once, the app folder list is the CLI's (~/.storebound/projects.json), and //! a folder that pins another storebound version says so. No real keys anywhere. use std::collections::HashMap; use std::sync::Mutex; use serde_json::json; use storebound_desktop::accounts::{Account, AccountKind, Secret, SecretStore}; use storebound_desktop::profiles::{self, ProfileStore}; use storebound_desktop::registry::{self, Entry}; use storebound_desktop::store::{Project, State}; use storebound_desktop::workspace::{pin_differs, pinned_storebound}; const PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PRIVATE KEY-----\n"; fn sa(email: &str) -> String { json!({ "type": "service_account", "client_email": email, "private_key": PEM }).to_string() } fn apple(key: &str) -> Secret { Secret::Apple { key_id: key.into(), issuer_id: "00000000-0000-0000-0000-000000000000".into(), p8: PEM.into() } } #[derive(Default)] struct MapStore(Mutex>); impl SecretStore for MapStore { fn set(&self, id: &str, s: &Secret) -> Result<(), String> { self.0.lock().unwrap().insert(id.into(), s.clone()); Ok(()) } fn get(&self, id: &str) -> Result { self.0.lock().unwrap().get(id).cloned().ok_or_else(|| "no entry".into()) } fn delete(&self, id: &str) -> Result<(), String> { self.0.lock().unwrap().remove(id); Ok(()) } } fn account(id: &str, label: &str, kind: AccountKind, profile: Option<&str>) -> Account { Account { id: id.into(), label: label.into(), kind, created: "2026-10-01T00:00:00Z".into(), studio: Some("s1".into()), profile: profile.map(str::to_string) } } #[test] fn ids_match_the_cli() { // cli/lib/workspace.ts accountId and cli/lib/registry.ts folderId give the same values assert_eq!(profiles::account_id(AccountKind::Apple, "acme"), "747351dbf2569d17"); assert_eq!(profiles::account_id(AccountKind::Play, "default"), "82a8c2561d950999"); assert_eq!(registry::folder_id("/w/Star Dash"), "f4994b9746e7eb18"); assert!(profiles::valid_name("default") && profiles::valid_name("acme-games.2") && !profiles::valid_name("../x") && !profiles::valid_name("-a")); assert_eq!(profiles::slug("Acme Games: Play!"), "acme-games-play"); assert_eq!(profiles::slug("???"), "account"); } #[test] fn a_profile_round_trips_in_the_cli_format_with_private_files() { let dir = tempfile::tempdir().unwrap(); let store = ProfileStore::new(dir.path().join("credentials"), None); store.save("acme", "Acme", &apple("ABCDE12345")).unwrap(); store.save("acme", "Acme", &Secret::Play { service_account: sa("bot@example.com") }).unwrap(); let m = store.read("acme").unwrap(); assert_eq!(m["ASC_P8"], "acme/app-store-connect.p8"); assert_eq!(m["PLAY_SERVICE_ACCOUNT"], "acme/play-service-account.json"); assert_eq!(m["label"], "Acme"); assert!(!serde_json::to_string(&m).unwrap().contains("not-a-real-key"), "keys stay in their own files"); assert_eq!(store.secret("acme", AccountKind::Apple).unwrap(), apple("ABCDE12345")); assert_eq!(store.kinds("acme"), vec![AccountKind::Apple, AccountKind::Play]); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; assert_eq!(mode(&store.dir.join("acme.json")), 0o600); assert_eq!(mode(&store.dir.join("acme").join(profiles::P8_FILE)), 0o600); assert_eq!(mode(&store.dir.join("acme")), 0o700); assert_eq!(mode(&store.dir), 0o700); } // the same key again lands in the same profile; another key in a profile of its own assert_eq!(store.place("Anything", &apple("ABCDE12345")), "acme"); assert_eq!(store.place("Acme", &apple("ZZZZZ99999")), "acme-apple"); assert_eq!(store.place("Acme", &Secret::Play { service_account: sa("bot@example.com") }), "acme"); // a profile the CLI wrote by hand, with an absolute key path and no ASC_P8 std::fs::write(store.dir.join("cli.json"), json!({ "ASC_KEY_ID": "CLI0000000", "ASC_ISSUER_ID": "i" }).to_string()).unwrap(); let mut creds = serde_json::Map::new(); store.secret("cli", AccountKind::Apple).unwrap().to_credentials(&mut creds); assert_eq!(creds.keys().cloned().collect::>(), vec!["ASC_ISSUER_ID", "ASC_KEY_ID"], "no ASC_P8: the CLI looks in ~/.appstoreconnect itself"); // removing one store archives its key file; the last store archives the profile store.remove("acme", AccountKind::Play, "20261013T000000Z").unwrap(); assert_eq!(store.kinds("acme"), vec![AccountKind::Apple]); assert!(store.dir.join(".removed/acme-20261013T000000Z").join(profiles::PLAY_FILE).is_file()); store.remove("acme", AccountKind::Apple, "20261013T000001Z").unwrap(); assert!(!store.names().contains(&"acme".to_string())); assert!(store.dir.join(".removed/acme-20261013T000001Z/acme.json").is_file()); } #[test] fn keychain_accounts_move_into_profiles_once_and_cli_profiles_show_up() { let dir = tempfile::tempdir().unwrap(); let store = ProfileStore::new(dir.path().join("credentials"), None); // the CLI already imported the shared account ("accounts import" -> profile default) store.save("default", "", &apple("ABCDE12345")).unwrap(); store.save("default", "", &Secret::Play { service_account: sa("bot@example.com") }).unwrap(); let legacy = MapStore::default(); legacy.set("0123456789abcdef", &Secret::Play { service_account: sa("bot@example.com") }).unwrap(); legacy.set("fedcba9876543210", &apple("SIDE000000")).unwrap(); let mut accs = vec![ account("0123456789abcdef", "Example Games Play", AccountKind::Play, None), account("fedcba9876543210", "Side project", AccountKind::Apple, None), account("00112233445566ff", "Lost key", AccountKind::Apple, None), ]; let r = profiles::sync(&mut accs, &store, &legacy, "2026-10-13T00:00:00Z"); // the same service account as the CLI's profile: that profile; the other key: a profile named after its label assert_eq!(accs[0].profile.as_deref(), Some("default")); assert_eq!(accs[1].profile.as_deref(), Some("side-project")); assert_eq!(accs[2].profile, None, "no keychain entry: stays, said in the log"); assert_eq!(accs[0].studio.as_deref(), Some("s1"), "id, label, studio stay"); assert!(legacy.get("0123456789abcdef").is_err() && legacy.get("fedcba9876543210").is_err(), "moved entries leave the keychain"); assert_eq!(r.migrated.len(), 2); assert_eq!(r.notes.len(), 1); // the CLI profile's Apple key had no app account yet: it comes in with the CLI workspace's id let added: Vec<_> = accs.iter().skip(3).map(|a| (a.id.clone(), a.kind, a.profile.clone())).collect(); assert_eq!(added, vec![(profiles::account_id(AccountKind::Apple, "default"), AccountKind::Apple, Some("default".into()))]); // a second start changes nothing let again = profiles::sync(&mut accs, &store, &legacy, "2026-10-14T00:00:00Z"); assert!(!again.changed()); assert_eq!(accs.len(), 4); } fn project(id: &str, path: &str, play: Option<&str>) -> Project { Project { id: id.into(), path: path.into(), name: id.into(), play_account: play.map(str::to_string), apple_account: None, added: String::new() } } #[test] fn the_folder_list_is_the_machines() { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("projects.json"); let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", "/w/Star Dash", Some("acc"))], ..State::default() }; // first start: the app's list becomes the shared one assert!(!registry::sync(&mut st, &file).unwrap()); assert_eq!(registry::read(&file).unwrap()[0].id, "aaaaaaaaaaaaaaaa"); // the CLI adds a folder (storebound desktop / apps add): the app shows it, the first keeps its accounts let mut list = registry::read(&file).unwrap(); list.push(Entry { id: registry::folder_id("/w/a game"), path: "/w/a game".into(), name: "a game".into(), added: "t".into() }); registry::write(&file, &list).unwrap(); assert!(registry::sync(&mut st, &file).unwrap()); assert_eq!(st.projects.iter().map(|p| p.path.as_str()).collect::>(), vec!["/w/Star Dash", "/w/a game"]); assert_eq!(st.projects[0].play_account.as_deref(), Some("acc")); // the CLI removes one (apps remove): it leaves the app with its overrides st.overrides.ignored.push(format!("project:{}", registry::folder_id("/w/a game"))); registry::write(&file, &list[..1]).unwrap(); assert!(registry::sync(&mut st, &file).unwrap()); assert_eq!(st.projects.len(), 1); assert!(st.overrides.ignored.is_empty()); // the app adds one; a folder another tool added meanwhile is kept st.projects.push(project("bbbbbbbbbbbbbbbb", "/w/third", None)); registry::write(&file, &[list[0].clone(), Entry { id: registry::folder_id("/w/cli"), path: "/w/cli".into(), name: "cli".into(), added: String::new() }]).unwrap(); registry::publish(&st, &file, &[]).unwrap(); let paths: Vec = registry::read(&file).unwrap().into_iter().map(|e| e.path).collect(); assert_eq!(paths, vec!["/w/Star Dash", "/w/third", "/w/cli"]); // bad entries are dropped, not the whole file assert_eq!(registry::parse(r#"{"projects":[{"path":"rel"},{"path":"/a","id":"XYZ"},{"path":"/a"}]}"#).iter().map(|e| e.id.clone()).collect::>(), vec![registry::folder_id("/a")]); } #[test] fn a_folder_that_pins_another_storebound_says_so() { let dir = tempfile::tempdir().unwrap(); assert_eq!(pinned_storebound(dir.path()), None); std::fs::write(dir.path().join("package.json"), r#"{"devDependencies":{"storebound":"^0.7.1"}}"#).unwrap(); assert_eq!(pinned_storebound(dir.path()).as_deref(), Some("^0.7.1")); assert!(pin_differs("^0.7.1", "0.8.0")); assert!(!pin_differs("0.8.0", "0.8.0")); assert!(!pin_differs("~0.8.0", "0.8.0")); for spec in ["latest", "*", "file:../storebound", "workspace:*", ">=0.7"] { assert!(!pin_differs(spec, "0.8.0"), "{spec}"); } }