- one version: package.json's version is the desktop app's too (desktopVersion removed); tools/desktop-version.ts keeps the plugin manifests and the app's copies equal, desktop-release publish refuses another version, release order app first then npm publish; the unpublished desktop 0.1.0 candidate is noted in the docs and CHANGELOG - storebound desktop installs (per user, no admin: macOS DMG -> /Applications or ~/Applications, Windows NSIS /S, Linux AppImage + .desktop with the storebound:// handler), updates an older app, notes a newer one (downgrade only with --force), asks once ([Y/n], --yes, exit 3 without a terminal), opens on the folder; --dry / --json / --download; the install steps are pure plans (cli/lib/desktop-install.ts); the version's own manifest dl/<version>/latest.json - credentials: one store, the CLI's credential profiles in ~/.storebound/credentials; the desktop writes and reads them (profiles.rs, files 0600), moves old keychain accounts into profiles on first start, shows profiles the CLI made; precedence env > named profile > project file > default profile; config `account`, "account" in the project's credentials.json; storebound accounts list | import (dedup by key id / service account e-mail, key files copied 0600, project file keeps keystore and review contact, backup, --dry, never prints values) | use - app folders: one list ~/.storebound/projects.json shared by the desktop (registry.rs), storebound desktop, apps folders | add | remove, ws init and ws without a workspace file - desktop: notice when a folder pins another storebound version; storebound-desktop --version - tests: version drift, desktop decisions and per-OS plans, credential precedence and import, registry (TS + Rust), keychain migration and profile round trip (Rust), boot check with old keychain state and the new shared state
99 lines
4.8 KiB
Rust
99 lines
4.8 KiB
Rust
//! Account handling: what is stored where, what is shown, and the key checks. No real keys anywhere.
|
|
use serde_json::{json, Map};
|
|
use storebound_desktop::accounts::*;
|
|
use storebound_desktop::commands::{dialog_excerpt, dialog_summary, has_config, tilde};
|
|
use storebound_desktop::sidecar::managed_tool_env;
|
|
use storebound_desktop::store::{self, Project, State};
|
|
|
|
const FAKE_PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PRIVATE KEY-----";
|
|
|
|
#[test]
|
|
fn shown_metadata_carries_no_secret() {
|
|
let a = Account { id: "abc".into(), label: "Studio Play".into(), kind: AccountKind::Play, created: "2026-10-10T00:00:00Z".into(), studio: Some("s1".into()), profile: Some("studio".into()) };
|
|
let v = serde_json::to_value(&a).unwrap();
|
|
assert_eq!(v.as_object().unwrap().keys().cloned().collect::<Vec<_>>(), vec!["created", "id", "kind", "label", "profile", "studio"]);
|
|
assert_eq!(v["kind"], "play");
|
|
}
|
|
|
|
#[test]
|
|
fn secrets_map_to_the_cli_env_names() {
|
|
let mut m = Map::new();
|
|
Secret::Apple { key_id: "ABCDEFGH12".into(), issuer_id: "00000000-0000-0000-0000-000000000000".into(), p8: FAKE_PEM.into() }.to_credentials(&mut m);
|
|
Secret::Play { service_account: "{}".into() }.to_credentials(&mut m);
|
|
assert_eq!(m.keys().cloned().collect::<Vec<_>>(), vec!["ASC_ISSUER_ID", "ASC_KEY_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT"]);
|
|
let s = Secret::Play { service_account: "{}".into() };
|
|
let back: Secret = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
|
assert_eq!(back, s);
|
|
}
|
|
|
|
#[test]
|
|
fn key_checks() {
|
|
let sa = json!({ "type": "service_account", "client_email": "bot@example.com", "private_key": FAKE_PEM }).to_string();
|
|
assert!(check_service_account(&sa).is_ok());
|
|
assert!(check_service_account("{\"client_email\":\"x@example.com\"}").is_err());
|
|
assert!(check_service_account("not json").is_err());
|
|
assert_eq!(check_key_id(" ABCDEFGH12 ").unwrap(), "ABCDEFGH12");
|
|
assert!(check_key_id("abc").is_err());
|
|
assert!(check_key_id("abcdefgh12").is_err());
|
|
let upper = "00000000-0000-0000-0000-00000000000A";
|
|
assert_eq!(check_issuer_id(upper).unwrap(), upper.to_ascii_lowercase());
|
|
assert!(check_issuer_id("0000-0000").is_err());
|
|
assert!(check_p8(FAKE_PEM).is_ok());
|
|
assert!(check_p8("hello").is_err());
|
|
assert!(check_label("").is_err());
|
|
assert!(check_label("a\u{7}").is_err());
|
|
assert!(check_label(&"x".repeat(61)).is_err());
|
|
assert_eq!(check_label(" Studio ").unwrap(), "Studio");
|
|
}
|
|
|
|
#[test]
|
|
fn state_file_round_trip_is_atomic_and_tolerant() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
assert_eq!(store::load(dir.path()), State::default());
|
|
let st = State {
|
|
projects: vec![Project { id: "1".into(), path: "/games/a".into(), name: "A".into(), play_account: Some("p".into()), apple_account: None, added: "2026-10-10T00:00:00Z".into() }],
|
|
accounts: vec![],
|
|
..State::default()
|
|
};
|
|
store::save(dir.path(), &st).unwrap();
|
|
assert_eq!(store::load(dir.path()), st);
|
|
assert!(!dir.path().join("state.json.tmp").exists());
|
|
let text = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
|
|
assert!(text.contains("\"playAccount\": \"p\""));
|
|
std::fs::write(dir.path().join("state.json"), "{broken").unwrap();
|
|
assert_eq!(store::load(dir.path()), State::default());
|
|
assert_eq!(store::iso_from_unix(0), "1970-01-01T00:00:00Z");
|
|
assert_eq!(store::iso_from_unix(1_700_000_000), "2023-11-14T22:13:20Z");
|
|
assert_eq!(store::new_id().unwrap().len(), 16);
|
|
}
|
|
|
|
#[test]
|
|
fn managed_tools_are_passed_only_when_present() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
assert!(managed_tool_env(dir.path()).is_empty());
|
|
std::fs::create_dir_all(dir.path().join("tools/bin")).unwrap();
|
|
let env = managed_tool_env(dir.path());
|
|
assert_eq!(env.len(), 1);
|
|
assert_eq!(env[0].0, "STOREBOUND_TOOL_PATH");
|
|
assert!(env[0].1.ends_with("bin"));
|
|
}
|
|
|
|
#[test]
|
|
fn confirm_dialog_text() {
|
|
let s = dialog_summary("Push Play listing text", "Title and descriptions.", "Prism Puddle", true, true);
|
|
assert!(s.starts_with("Push Play listing text · Prism Puddle"));
|
|
assert!(s.contains("not public until you submit"));
|
|
assert!(s.contains("replaces"));
|
|
let out: String = (1..=40).map(|i| format!("line {i}\n")).collect();
|
|
let ex = dialog_excerpt(&out);
|
|
assert!(ex.starts_with("(12 earlier lines"));
|
|
assert!(ex.ends_with("line 40"));
|
|
assert!(dialog_excerpt(&"x".repeat(5000)).chars().count() < 2_500);
|
|
assert_eq!(tilde("would write /home/you/a/index.html", Some("/home/you/")), "would write ~/a/index.html");
|
|
assert_eq!(tilde("/x", None), "/x");
|
|
assert_eq!(tilde("/x", Some("/")), "/x");
|
|
let dir = tempfile::tempdir().unwrap();
|
|
assert!(!has_config(dir.path()));
|
|
std::fs::write(dir.path().join("storebound.config.ts"), "export default {}").unwrap();
|
|
assert!(has_config(dir.path()));
|
|
}
|