storebound/apps/desktop/src-tauri/tests/workspace.rs

160 lines
7.5 KiB
Rust

//! Desktop D3: studios, store app keys and store-only targets, pairing / ignore overrides, settings, old state files.
use serde_json::json;
use storebound_desktop::cache::Cache;
use storebound_desktop::policy::{sanitize_js_call, Kind};
use storebound_desktop::store::{self, State};
use storebound_desktop::workspace::*;
const A1: &str = "0123456789abcdef";
const A2: &str = "fedcba9876543210";
#[test]
fn studio_names_colours_and_logos() {
assert_eq!(check_studio_name(" Example Games ").unwrap(), "Example Games");
assert!(check_studio_name("").is_err());
assert!(check_studio_name(&"x".repeat(49)).is_err());
assert!(check_studio_name("a\nb").is_err());
for c in STUDIO_COLORS {
assert!(check_color(c).is_ok());
}
assert!(check_color("#ff0000").is_err());
assert!(check_color("red; background:url(x)").is_err());
assert!(logo_data_url(&[0x89, b'P', b'N', b'G', 1, 2]).unwrap().starts_with("data:image/png;base64,"));
assert!(logo_data_url(&[0xff, 0xd8, 0xff, 0]).unwrap().starts_with("data:image/jpeg;base64,"));
assert!(logo_data_url(b"RIFF\0\0\0\0WEBPVP8 ").unwrap().starts_with("data:image/webp;"));
assert!(logo_data_url(b"<svg onload=alert(1)>").is_err(), "no SVG (scripts) as a logo");
assert!(logo_data_url(&vec![0x89; LOGO_MAX + 1]).is_err());
}
#[test]
fn store_app_keys() {
assert_eq!(StoreAppKey::parse(&format!("ios:{A1}:6400000101")).unwrap(), StoreAppKey::Ios { account: A1.into(), app_id: "6400000101".into() });
assert_eq!(StoreAppKey::parse(&format!("android:{A2}:com.example.puzzle")).unwrap().account(), A2);
for bad in [
format!("ios:{A1}:com.example.puzzle"),
format!("android:{A1}:6400000101"),
"ios:nothex0123456789:1".to_string(),
format!("mac:{A1}:1"),
format!("android:{A1}:com.example.puzzle/../x"),
format!("android:{A1}:.example"),
format!("ios:{}:1", A1.to_uppercase()),
] {
assert!(StoreAppKey::parse(&bad).is_err(), "{bad}");
}
}
#[test]
fn store_only_targets_become_virtual_projects_with_store_ids_only() {
let t = StoreTarget::parse(&format!("store:ios:{A1}:6400000101:com.example.puzzle;android:{A2}:com.example.puzzle")).unwrap();
assert_eq!(t.accounts(), vec![A1, A2]);
assert_eq!(
t.virtual_project(),
json!({ "name": "com.example.puzzle", "apple": { "bundleId": "com.example.puzzle", "appId": "6400000101" }, "play": { "packageName": "com.example.puzzle" } })
);
let only = StoreTarget::parse(&format!("store:android:{A2}:com.example.runner")).unwrap();
assert_eq!(only.virtual_project(), json!({ "name": "com.example.runner", "play": { "packageName": "com.example.runner" } }));
for bad in [
"store:".to_string(),
"ios:x".to_string(),
format!("store:ios:{A1}:6400000101"),
format!("store:ios:{A1}:6400000101:com.example.a;ios:{A1}:6400000102:com.example.b"),
format!("store:android:{A2}:com.example.a;android:{A2}:com.example.b"),
format!("store:android:{A2}:com.example.a;path:/etc"),
format!("store:ios:{A1}:6400000101:../../etc"),
format!("store:android:{A2}:com.example.a;{}", "x".repeat(400)),
] {
assert!(StoreTarget::parse(&bad).is_err(), "{bad}");
}
}
#[test]
fn overrides_are_checked_entry_by_entry() {
let ok = Overrides {
pairs: vec![Pair { ios: format!("ios:{A1}:6400000101"), android: format!("android:{A2}:com.example.puzzle") }],
unpairs: vec![Pair { ios: format!("ios:{A1}:6400000102"), android: format!("android:{A2}:com.example.notes") }],
links: vec![Link { key: format!("ios:{A1}:6400000103"), project: "p1".into() }],
app_studio: vec![AppStudio { key: format!("android:{A2}:com.example.runner"), studio: "s1".into() }, AppStudio { key: "project:p1".into(), studio: "s2".into() }],
ignored: vec![format!("app:ios:{A1}:6400000104"), format!("account:{A2}"), "project:p9".into()],
};
assert!(check_overrides(&ok).is_ok());
let mut swapped = ok.clone();
swapped.pairs = vec![Pair { ios: format!("android:{A2}:com.example.puzzle"), android: format!("ios:{A1}:6400000101") }];
assert!(check_overrides(&swapped).is_err());
for bad in ["app:ios:x:1", "account:short", "project:", "everything", "app:"] {
let mut o = ok.clone();
o.ignored.push(bad.into());
assert!(check_overrides(&o).is_err(), "{bad}");
}
let many = Overrides { ignored: (0..=MAX_OVERRIDES).map(|i| format!("project:p{i}")).collect(), ..Default::default() };
assert!(check_overrides(&many).is_err());
// camelCase on the wire
let v = serde_json::to_value(&ok).unwrap();
assert!(v.get("appStudio").is_some());
}
#[test]
fn settings_have_safe_defaults_and_bounds() {
let d = Settings::default();
assert!(check_settings(&d).is_ok());
assert_eq!((d.refresh.auto_minutes, d.refresh.on_launch, d.refresh.parallel, d.cache.keep_days, d.channel.as_str()), (0, false, 4, 30, "stable"));
let mut s = d.clone();
s.refresh.parallel = 0;
assert!(check_settings(&s).is_err());
s = d.clone();
s.refresh.parallel = 9;
assert!(check_settings(&s).is_err());
s = d.clone();
s.refresh.auto_minutes = 1;
assert!(check_settings(&s).is_err(), "no tight polling of the store APIs");
s = d.clone();
s.cache.keep_days = 3;
assert!(check_settings(&s).is_err());
s = d;
s.channel = "nightly".into();
assert!(check_settings(&s).is_err());
let v = serde_json::to_value(Settings::default()).unwrap();
assert_eq!(v["refresh"]["autoMinutes"], 0);
assert_eq!(v["cache"]["keepDays"], 30);
assert_eq!(v["notify"]["lowReviews"], true);
}
#[test]
fn a_d2_state_file_still_loads_with_empty_d3_parts() {
let dir = tempfile::tempdir().unwrap();
let d2 = json!({
"projects": [{ "id": "p1", "path": "/x", "name": "X", "playAccount": null, "appleAccount": null, "added": "2026-10-01T00:00:00Z" }],
"accounts": [{ "id": A1, "label": "Play", "kind": "play", "created": "2026-10-01T00:00:00Z" }]
});
std::fs::write(dir.path().join(store::FILE), d2.to_string()).unwrap();
let st = store::load(dir.path());
assert_eq!(st.projects.len(), 1);
assert_eq!(st.accounts[0].studio, None);
assert!(st.studios.is_empty());
assert_eq!(st.overrides, Overrides::default());
assert_eq!(st.settings, Settings::default());
store::save(dir.path(), &st).unwrap();
assert_eq!(store::load(dir.path()), st);
assert_eq!(State::default().settings.refresh.parallel, 4);
}
#[test]
fn account_reads_are_the_hosts() {
// the window cannot call apps.list / account.check directly: only the account commands (one key per call)
assert!(sanitize_js_call("apps.list", Some(Kind::Account), &json!({ "credentials": {} })).is_err());
assert!(sanitize_js_call("account.check", Some(Kind::Account), &json!({})).is_err());
assert_eq!(Kind::parse("account"), Some(Kind::Account));
}
#[test]
fn cache_info_and_pruning_by_age() {
let c = Cache::in_memory().unwrap();
c.put("p", "health", &json!({}), &json!({ "n": 1 }), "2026-01-01T00:00:00Z").unwrap();
c.put("p", "reviews.list", &json!({}), &json!({ "n": 2 }), "2026-10-01T00:00:00Z").unwrap();
c.put("acct:x", "apps.list", &json!({}), &json!({ "apps": [] }), "2026-10-10T00:00:00Z").unwrap();
let (rows, bytes, oldest) = c.info().unwrap();
assert_eq!((rows, oldest.as_deref()), (3, Some("2026-01-01T00:00:00Z")));
assert!(bytes > 0);
assert_eq!(c.prune_before("2026-09-01T00:00:00Z").unwrap(), 1);
assert!(c.get("p", "health", &json!({})).unwrap().is_none());
assert!(c.get("p", "reviews.list", &json!({})).unwrap().is_some());
}