271 lines
14 KiB
Rust
271 lines
14 KiB
Rust
//! Saved state: old formats migrate to the current one (with a backup of the original), damaged files and entries
|
|
//! never take the rest down, missing folders are reported as such, Reset keeps a backup, and the logs stay clean.
|
|
use serde_json::{json, Value};
|
|
use storebound_desktop::commands::project_view;
|
|
use storebound_desktop::logs;
|
|
use storebound_desktop::store::{self, State, VERSION};
|
|
use storebound_desktop::accounts::{AccountKind, Secret, SecretStore};
|
|
use storebound_desktop::profiles::{self, ProfileStore};
|
|
use storebound_desktop::registry;
|
|
use storebound_desktop::workspace::{Overrides, Settings};
|
|
|
|
const D1: &str = include_str!("../../tests/fixtures/state/d1-missing-folders.json");
|
|
const D3: &str = include_str!("../../tests/fixtures/state/d3-full.json");
|
|
const EMPTY: &str = include_str!("../../tests/fixtures/state/empty.json");
|
|
const SHARED: &str = include_str!("../../tests/fixtures/state/shared-profiles.json");
|
|
|
|
/// Keychain entries of versions before 0.8, for the migration into credential profiles.
|
|
#[derive(Default)]
|
|
struct Keychain(std::sync::Mutex<std::collections::HashMap<String, Secret>>);
|
|
impl SecretStore for Keychain {
|
|
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(())
|
|
}
|
|
}
|
|
const FAKE_PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PRIVATE KEY-----\n";
|
|
fn fake_secret(kind: AccountKind, n: usize) -> Secret {
|
|
match kind {
|
|
AccountKind::Apple => Secret::Apple { key_id: format!("FAKEKEY{n:03}"), issuer_id: "00000000-0000-0000-0000-000000000000".into(), p8: FAKE_PEM.into() },
|
|
AccountKind::Play => Secret::Play { service_account: json!({ "client_email": format!("bot{n}@example.iam.gserviceaccount.com"), "private_key": FAKE_PEM }).to_string() },
|
|
}
|
|
}
|
|
|
|
fn write(dir: &std::path::Path, text: &str) {
|
|
std::fs::write(dir.join(store::FILE), text).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn d1_state_with_missing_folders_migrates_to_the_current_format() {
|
|
let (st, from, notes) = store::migrate(serde_json::from_str(D1).unwrap());
|
|
assert_eq!((from, st.version), (1, VERSION));
|
|
assert!(notes.is_empty(), "{notes:?}");
|
|
assert_eq!(st.projects.len(), 2);
|
|
assert_eq!(st.projects[0].name, "Prism Puddle");
|
|
assert!(st.accounts.is_empty() && st.studios.is_empty());
|
|
assert_eq!((st.overrides.clone(), st.settings.clone()), (Overrides::default(), Settings::default()));
|
|
// the folders are gone: the window gets `missing: true` for each, nothing fails
|
|
for p in &st.projects {
|
|
let v = serde_json::to_value(project_view(p)).unwrap();
|
|
assert_eq!(v["missing"], true);
|
|
assert_eq!(v["path"], p.path.as_str());
|
|
assert!(v.get("playAccount").is_some(), "camelCase, flattened");
|
|
}
|
|
|
|
// on disk: migrated once, the original kept as state.json.v1.bak, the new file carries the version
|
|
let dir = tempfile::tempdir().unwrap();
|
|
write(dir.path(), D1);
|
|
let (loaded, report) = store::load_report(dir.path());
|
|
assert_eq!(loaded, st);
|
|
assert_eq!(report.migrated_from, Some(1));
|
|
let bak = dir.path().join("state.json.v1.bak");
|
|
assert_eq!(report.backup.as_deref(), Some(bak.as_path()));
|
|
assert_eq!(std::fs::read_to_string(&bak).unwrap(), D1);
|
|
let saved: Value = serde_json::from_str(&std::fs::read_to_string(dir.path().join(store::FILE)).unwrap()).unwrap();
|
|
assert_eq!(saved["version"], VERSION);
|
|
assert_eq!(saved["settings"]["cache"]["keepDays"], 30);
|
|
let (again, report) = store::load_report(dir.path());
|
|
assert_eq!(again, st);
|
|
assert_eq!(report.migrated_from, None, "migrates only once");
|
|
}
|
|
|
|
#[test]
|
|
fn d3_state_without_version_keeps_everything() {
|
|
let (st, from, notes) = store::migrate(serde_json::from_str(D3).unwrap());
|
|
assert_eq!(from, 1);
|
|
assert!(notes.is_empty(), "{notes:?}");
|
|
assert_eq!((st.projects.len(), st.accounts.len(), st.studios.len()), (2, 3, 2));
|
|
assert_eq!(st.accounts[0].studio.as_deref(), Some("s1"));
|
|
assert_eq!(st.accounts[2].studio, None);
|
|
assert_eq!(st.studios[1].logo, None);
|
|
assert_eq!(st.overrides.ignored, vec!["account:00112233445566ff".to_string()]);
|
|
assert_eq!(st.overrides.app_studio[0].studio, "s2");
|
|
}
|
|
|
|
#[test]
|
|
fn empty_and_missing_files_start_empty() {
|
|
let (st, from, _) = store::migrate(serde_json::from_str(EMPTY).unwrap());
|
|
assert_eq!(from, 1);
|
|
assert_eq!(st, State::default());
|
|
let dir = tempfile::tempdir().unwrap();
|
|
assert_eq!(store::load_report(dir.path()), (State::default(), store::LoadReport::default()));
|
|
assert!(!dir.path().join(store::FILE).exists(), "nothing written for a fresh install");
|
|
}
|
|
|
|
#[test]
|
|
fn partial_settings_keep_what_is_there() {
|
|
let (st, from, notes) = store::migrate(json!({ "version": 2, "settings": { "refresh": { "autoMinutes": 15 }, "channel": "beta" } }));
|
|
assert_eq!(from, 2);
|
|
assert!(notes.is_empty(), "{notes:?}");
|
|
assert_eq!((st.settings.refresh.auto_minutes, st.settings.refresh.parallel, st.settings.cache.keep_days, st.settings.channel.as_str()), (15, 4, 30, "beta"));
|
|
assert!(st.settings.notify.crash);
|
|
}
|
|
|
|
#[test]
|
|
fn bad_entries_are_dropped_one_by_one() {
|
|
let (st, _, notes) = store::migrate(json!({
|
|
"projects": [
|
|
{ "id": "a", "path": "/x/a", "name": "A", "added": "2026-10-01T00:00:00Z" },
|
|
{ "id": 7 },
|
|
{ "id": "b", "path": "/x/Beta" },
|
|
{ "id": "a", "path": "/x/dup", "name": "Dup" }
|
|
],
|
|
"accounts": [{ "id": "0123456789abcdef", "label": "P", "kind": "play", "created": "2026-10-01T00:00:00Z" }, { "kind": "windows-phone" }],
|
|
"studios": "not a list",
|
|
"overrides": { "ignored": "nope" },
|
|
"settings": null
|
|
}));
|
|
assert_eq!(st.projects.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), vec!["A", "Beta"], "no name: the folder name; duplicate id: first wins");
|
|
assert_eq!(st.accounts.len(), 1);
|
|
assert!(st.studios.is_empty());
|
|
assert_eq!(st.overrides, Overrides::default());
|
|
assert_eq!(st.settings, Settings::default());
|
|
assert_eq!(notes.len(), 3, "{notes:?}");
|
|
assert!(notes[0].starts_with("projects[1] dropped"));
|
|
}
|
|
|
|
#[test]
|
|
fn unreadable_files_are_kept_aside_and_newer_formats_read() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
write(dir.path(), "{broken");
|
|
let (st, report) = store::load_report(dir.path());
|
|
assert_eq!(st, State::default());
|
|
let bak = report.backup.expect("a copy of the unreadable file");
|
|
assert!(bak.file_name().unwrap().to_string_lossy().starts_with("state.json.unreadable-"));
|
|
assert_eq!(std::fs::read_to_string(bak).unwrap(), "{broken");
|
|
write(dir.path(), "[1,2]");
|
|
assert_eq!(store::load(dir.path()), State::default());
|
|
|
|
let (st, from, _) = store::migrate(json!({ "version": 99, "projects": [{ "id": "a", "path": "/x", "name": "A", "added": "" }], "future": true }));
|
|
assert_eq!((from, st.version, st.projects.len()), (99, 99, 1));
|
|
let dir = tempfile::tempdir().unwrap();
|
|
write(dir.path(), &json!({ "version": 99 }).to_string());
|
|
let (_, report) = store::load_report(dir.path());
|
|
assert!(report.notes[0].contains("newer"), "{:?}", report.notes);
|
|
assert_eq!(report.migrated_from, None);
|
|
}
|
|
|
|
#[test]
|
|
fn reset_moves_the_file_aside() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
assert_eq!(store::reset(dir.path()).unwrap(), None);
|
|
write(dir.path(), D1);
|
|
let bak = store::reset(dir.path()).unwrap().unwrap();
|
|
assert!(bak.file_name().unwrap().to_string_lossy().starts_with("state.json.reset-"));
|
|
assert_eq!(std::fs::read_to_string(bak).unwrap(), D1);
|
|
assert!(!dir.path().join(store::FILE).exists());
|
|
assert_eq!(store::load(dir.path()), State::default());
|
|
}
|
|
|
|
#[test]
|
|
fn saved_files_always_carry_the_version() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
store::save(dir.path(), &State { version: 0, ..State::default() }).unwrap();
|
|
let v: Value = serde_json::from_str(&std::fs::read_to_string(dir.path().join(store::FILE)).unwrap()).unwrap();
|
|
assert_eq!(v["version"], VERSION);
|
|
}
|
|
|
|
#[test]
|
|
fn logs_hide_home_keys_tokens_and_emails() {
|
|
let home = Some("/Users/example");
|
|
let s = logs::sanitize("TypeError at /Users/example/Library/x.js:1\n\tat f", home);
|
|
assert_eq!(s, "TypeError at ~/Library/x.js:1\n\tat f");
|
|
let pem = "key: -----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49\n-----END PRIVATE KEY----- done";
|
|
assert_eq!(logs::sanitize(pem, None), "key: [redacted key] done");
|
|
let s = logs::sanitize(r#"{"password": "hunter22", "token":"abc.def", "name":"ok"} Authorization: Bearer xyz123 url?api_key=K1&x=1"#, None);
|
|
assert!(!s.contains("hunter22") && !s.contains("abc.def") && !s.contains("xyz123") && !s.contains("K1&"), "{s}");
|
|
assert!(s.contains(r#""name":"ok""#), "{s}");
|
|
let s = logs::sanitize("tester person@example.com saw eyJhbGciOiJFUzI1NiJ9abcdef1234567890ABCDEF and 0123456789abcdef0123456789abcdef01", None);
|
|
assert!(!s.contains("person@example.com") && !s.contains("eyJhbGci") && !s.contains("0123456789abcdef0123"), "{s}");
|
|
assert!(s.contains("[email]"));
|
|
// ordinary paths, file names and error text stay readable
|
|
let ok = "at qg (tauri://localhost/assets/index-DXUigr_F.js:49:175893) Cannot read properties of undefined (reading 'map')";
|
|
assert_eq!(logs::sanitize(ok, home), ok);
|
|
assert_eq!(logs::sanitize("a\u{7}b\u{1b}[31m", None), "ab[31m");
|
|
}
|
|
|
|
#[test]
|
|
fn log_entries_are_one_block_and_rotate() {
|
|
let e = logs::entry("2026-10-11T00:00:00Z", "webview", "error", "boom\nat f\n");
|
|
assert_eq!(e, "2026-10-11T00:00:00Z error [webview] boom\n at f\n");
|
|
let long = "x".repeat(logs::MAX_ENTRY + 10);
|
|
assert!(logs::entry("t", "host", "info", &long).ends_with(" [cut]\n"));
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(dir.path().join("host.log"), vec![b'a'; logs::MAX_BYTES as usize]).unwrap();
|
|
logs::append(dir.path(), "host.log", "new\n").unwrap();
|
|
assert_eq!(std::fs::read_to_string(dir.path().join("host.log")).unwrap(), "new\n");
|
|
assert_eq!(std::fs::metadata(dir.path().join("host.1.log")).unwrap().len(), logs::MAX_BYTES);
|
|
}
|
|
|
|
/// What the host answers for each fixture (projects_list without `missing`, accounts_list, workspace_get), written
|
|
/// next to it as `<name>.host.json`, after the start-up sync with the machine's shared stores: D1 keeps its accounts in a
|
|
/// keychain that has no entries (the keys were never added: the accounts stay without a profile), D3's keychain has
|
|
/// them (they move into credential profiles), and shared-profiles is the 0.8 format whose apple account comes from a
|
|
/// profile the CLI wrote, with a folder that pins storebound 0.6.0 and a store-only app of that profile (`storebound
|
|
/// apps add --store apple --app-id … --account default`: store_apps_cached lists it under the apple account).
|
|
/// tools/desktop-boot-check.ts boots the built window on exactly these answers.
|
|
/// After a change to the state format: STOREBOUND_UPDATE_FIXTURES=1 cargo test --test state
|
|
#[test]
|
|
fn host_answers_for_the_fixtures_are_current() {
|
|
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/fixtures/state");
|
|
for (name, text, keychain) in [("empty", EMPTY, false), ("d1-missing-folders", D1, false), ("d3-full", D3, true), ("shared-profiles", SHARED, false)] {
|
|
let (mut st, _, notes) = store::migrate(serde_json::from_str(text).unwrap());
|
|
assert!(notes.is_empty(), "{name}: {notes:?}");
|
|
let machine = tempfile::tempdir().unwrap();
|
|
let profiles = ProfileStore::new(machine.path().join("credentials"), None);
|
|
let legacy = Keychain::default();
|
|
if keychain {
|
|
for (i, a) in st.accounts.iter().enumerate() {
|
|
legacy.set(&a.id, &fake_secret(a.kind, i)).unwrap();
|
|
}
|
|
}
|
|
if name == "shared-profiles" {
|
|
profiles.save("default", "Acme Games", &fake_secret(AccountKind::Play, 0)).unwrap();
|
|
profiles.save("default", "Acme Games", &fake_secret(AccountKind::Apple, 0)).unwrap();
|
|
}
|
|
profiles::sync(&mut st.accounts, &profiles, &legacy, "2026-10-13T00:00:00Z");
|
|
registry::sync(&mut st, &machine.path().join("projects.json")).unwrap();
|
|
let mut projects = serde_json::to_value(&st.projects).unwrap();
|
|
if name == "shared-profiles" {
|
|
// what project_view says for a folder whose package.json pins storebound 0.6.0
|
|
let pinned = tempfile::tempdir().unwrap();
|
|
std::fs::write(pinned.path().join("package.json"), r#"{"devDependencies":{"storebound":"0.6.0"}}"#).unwrap();
|
|
let mut p = st.projects[0].clone();
|
|
p.path = pinned.path().to_string_lossy().to_string();
|
|
let pin = project_view(&p).storebound_pin.expect("0.6.0 is not this app");
|
|
projects[0]["storeboundPin"] = json!(pin);
|
|
}
|
|
// store_apps_cached before any store read: only the machine's store-only entries (registry.rs)
|
|
let store_only = if name == "shared-profiles" {
|
|
registry::parse_store_apps(r#"{"storeApps":[{"store":"apple","appId":"6400000999","account":"default","name":"Example Aquarium","bundleId":"com.example.aquarium"}]}"#)
|
|
} else {
|
|
vec![]
|
|
};
|
|
let cached: Vec<serde_json::Value> = st.accounts.iter().map(|a| {
|
|
let store = match a.kind { AccountKind::Apple => "apple", AccountKind::Play => "play" };
|
|
let only = st.accounts.iter().filter(|x| x.kind == a.kind).count() == 1;
|
|
let entry = registry::with_store_only(None, store, a.profile.as_deref(), only, &store_only, "2026-10-13T00:00:00Z").map(|value| json!({ "value": value, "at": "2026-10-13T00:00:00Z" }));
|
|
json!({ "account": a.id, "entry": entry })
|
|
}).collect();
|
|
let host = json!({
|
|
"projects_list": projects,
|
|
"accounts_list": st.accounts,
|
|
"store_apps_cached": cached,
|
|
"workspace_get": { "studios": st.studios, "overrides": st.overrides, "settings": st.settings },
|
|
});
|
|
let want = format!("{}\n", serde_json::to_string_pretty(&host).unwrap());
|
|
let file = dir.join(format!("{name}.host.json"));
|
|
if std::env::var_os("STOREBOUND_UPDATE_FIXTURES").is_some() {
|
|
std::fs::write(&file, &want).unwrap();
|
|
}
|
|
let have = std::fs::read_to_string(&file).unwrap_or_default();
|
|
assert!(have == want, "{} is stale: STOREBOUND_UPDATE_FIXTURES=1 cargo test --test state", file.display());
|
|
}
|
|
}
|