262 lines
16 KiB
Rust
262 lines
16 KiB
Rust
//! 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<HashMap<String, Secret>>);
|
|
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<Secret, String> {
|
|
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), developer_id: None }
|
|
}
|
|
|
|
#[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"), "c27311c5023dc3ba");
|
|
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<_>>(), 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/demo-game"), path: "/w/demo-game".into(), name: "demo-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<_>>(), vec!["/w/star-dash", "/w/demo-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/demo-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<String> = 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<_>>(), vec![registry::folder_id("/a")]);
|
|
}
|
|
|
|
#[test]
|
|
fn store_only_apps_join_their_profiles_account_and_survive_folder_writes() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let file = dir.path().join("projects.json");
|
|
// as `storebound apps add --store apple --app-id 6400000999 --account teamA` and a Play one without a profile wrote it
|
|
std::fs::write(&file, r#"{"version":1,"projects":[],"storeApps":[
|
|
{"id":"x","store":"apple","appId":"6400000999","account":"teamA","name":"Example Aquarium","bundleId":"com.example.aquarium","added":"t"},
|
|
{"store":"play","appId":"com.example.legacy","name":"Legacy"},
|
|
{"store":"apple","appId":"not-a-number"}]}"#).unwrap();
|
|
let apps = registry::read_store_apps(&file);
|
|
assert_eq!(apps.iter().map(|a| a.app_id.as_str()).collect::<Vec<_>>(), vec!["6400000999", "com.example.legacy"]);
|
|
assert_eq!(apps[1].bundle_id.as_deref(), Some("com.example.legacy"));
|
|
// the app writes its folder list: the store-only entries stay
|
|
let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", "/w/star-dash", None)], ..State::default() };
|
|
registry::publish(&st, &file, &[]).unwrap();
|
|
assert_eq!(registry::read_store_apps(&file).len(), 2);
|
|
assert!(registry::sync(&mut st, &file).is_ok());
|
|
assert_eq!(registry::read_store_apps(&file).len(), 2);
|
|
// an account of profile teamA that never listed its apps: the entry shows as its app, read-only, no folder
|
|
let v = registry::with_store_only(None, "apple", Some("teamA"), false, &apps, "t").unwrap();
|
|
assert_eq!(v["store"], "ios");
|
|
assert_eq!(v["apps"][0]["id"], "6400000999");
|
|
assert_eq!(v["apps"][0]["bundleId"], "com.example.aquarium");
|
|
assert_eq!(v["apps"][0]["registered"], true);
|
|
// an account of another profile gets nothing; the list the store returned is kept, the entry not doubled
|
|
assert_eq!(registry::with_store_only(None, "apple", Some("teamB"), false, &apps, "t"), None);
|
|
let listed = serde_json::json!({ "store": "ios", "apps": [{ "id": "6400000999", "name": "From the store" }], "warnings": [], "at": "t" });
|
|
assert_eq!(registry::with_store_only(Some(listed.clone()), "apple", Some("teamA"), false, &apps, "t"), Some(listed));
|
|
// no profile named: the only Play account takes it
|
|
let p = registry::with_store_only(None, "play", Some("any"), true, &apps, "t").unwrap();
|
|
assert_eq!(p["apps"][0]["name"], "Legacy");
|
|
assert_eq!(registry::with_store_only(None, "play", Some("any"), false, &apps, "t"), None);
|
|
}
|
|
|
|
#[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}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ignored_flags_travel_between_the_registry_and_the_apps_ignore_keys() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let file = dir.path().join("projects.json");
|
|
// as `storebound apps ignore` wrote it: one folder fully, one store side of another, one store-only app
|
|
let (one, two) = (dir.path().join("one").to_string_lossy().to_string(), dir.path().join("two").to_string_lossy().to_string());
|
|
let doc = json!({ "version": 1, "projects": [
|
|
{ "id": "aaaaaaaaaaaaaaaa", "path": one, "name": "One", "added": "t", "ignored": ["apple", "play"] },
|
|
{ "id": "bbbbbbbbbbbbbbbb", "path": two, "name": "Two", "added": "t", "ignored": ["play"] }],
|
|
"storeApps": [{ "store": "apple", "appId": "6400000999", "account": "teamA", "name": "Demo", "ignored": ["apple"] }] });
|
|
std::fs::write(&file, doc.to_string()).unwrap();
|
|
let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", &one, None), project("bbbbbbbbbbbbbbbb", &two, None)], ..State::default() };
|
|
st.accounts = vec![account("acct1", "Team A", AccountKind::Apple, Some("teamA"))];
|
|
st.overrides.ignored = vec!["account:zzz".into(), "app:ios:other:6400000999".into()];
|
|
assert!(registry::pull_ignored(&mut st, &file));
|
|
assert!(st.overrides.ignored.contains(&"account:zzz".to_string()));
|
|
assert!(st.overrides.ignored.contains(&"project:aaaaaaaaaaaaaaaa".to_string()));
|
|
assert!(st.overrides.ignored.contains(&"project:bbbbbbbbbbbbbbbb:android".to_string()));
|
|
assert!(st.overrides.ignored.contains(&"app:ios:acct1:6400000999".to_string()));
|
|
assert!(!st.overrides.ignored.contains(&"app:ios:other:6400000999".to_string()));
|
|
assert!(!registry::pull_ignored(&mut st, &file), "second pull changes nothing");
|
|
// the owner restores one folder's play side and ignores the other's apple side in the app
|
|
st.overrides.ignored.retain(|k| k != "project:bbbbbbbbbbbbbbbb:android" && k != "app:ios:acct1:6400000999");
|
|
st.overrides.ignored.push("project:bbbbbbbbbbbbbbbb:ios".into());
|
|
assert!(registry::push_ignored(&st, &file).unwrap());
|
|
let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
|
|
assert_eq!(doc["projects"][0]["ignored"], json!(["apple", "play"]));
|
|
assert_eq!(doc["projects"][1]["ignored"], json!(["apple"]));
|
|
assert!(doc["storeApps"][0].get("ignored").is_none());
|
|
assert!(!registry::push_ignored(&st, &file).unwrap());
|
|
// writing the folder list keeps the flags of the folders that stay
|
|
let list = registry::read(&file).unwrap();
|
|
registry::write(&file, &list).unwrap();
|
|
let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
|
|
assert_eq!(doc["projects"][1]["ignored"], json!(["apple"]));
|
|
}
|
|
|
|
#[test]
|
|
fn a_play_developer_id_is_kept_in_the_profile_for_the_cli() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let store = ProfileStore::new(dir.path().join("credentials"), None);
|
|
store.save("studio", "Studio", &Secret::Play { service_account: sa("bot@example.com") }).unwrap();
|
|
assert_eq!(store.play_developer_id("studio"), None);
|
|
store.set_play_developer_id("studio", Some("5550000000000000001")).unwrap();
|
|
assert_eq!(store.play_developer_id("studio").as_deref(), Some("5550000000000000001"));
|
|
let m = store.read("studio").unwrap();
|
|
assert_eq!(m.get("PLAY_DEVELOPER_ID").and_then(|v| v.as_str()), Some("5550000000000000001"));
|
|
assert!(m.contains_key("PLAY_SERVICE_ACCOUNT"));
|
|
assert!(store.set_play_developer_id("studio", Some("abc")).is_err());
|
|
store.set_play_developer_id("studio", None).unwrap();
|
|
assert_eq!(store.play_developer_id("studio"), None);
|
|
}
|