storebound/apps/desktop/src-tauri/tests/accounts.rs
CRT d2ce185441 One product per machine: the CLI and the desktop app share one version, the store accounts and the app folder list
- 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
2026-10-13 06:17:37 +08:00

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()));
}