160 lines
7.5 KiB
Rust
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());
|
|
}
|