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

125 lines
8.2 KiB
Rust

//! Desktop D3 writes: owner actions are dry runs from the window like every write; only `write_with_confirm` runs them,
//! after a dialog that names the action on its button and says users see it at once. Tokens are bound to exactly the
//! method and the { project, args } sent (the same vector as the TypeScript side checks).
use serde_json::json;
use storebound_desktop::commands::{console_fill_dialog, dialog_labels, dialog_summary, owner_summary, summary_for};
use storebound_desktop::confirm::{canonical, mac, mint};
use storebound_desktop::policy::{sanitize_js_call, Kind, CONSOLE_FILL, CONSOLE_FILL_NOTE, SUBMIT_METHODS};
#[test]
fn owner_actions_are_dry_runs_from_the_window() {
let p = sanitize_js_call("owner.play_rollout", Some(Kind::Owner), &json!({ "args": { "action": "complete" } })).unwrap();
assert_eq!(p["dryRun"], json!(true));
for bad in [json!({ "dryRun": false }), json!({ "confirmToken": "sbt1.x" }), json!({ "credentials": {} }), json!({ "project": "/x" })] {
assert!(sanitize_js_call("owner.reply_send", Some(Kind::Owner), &bad).is_err(), "{bad}");
}
assert_eq!(Kind::parse("owner"), Some(Kind::Owner));
let w = sanitize_js_call("reviews.reply", Some(Kind::Write), &json!({ "args": { "file": "play-1.md", "text": "Thanks" } })).unwrap();
assert_eq!(w["dryRun"], json!(true));
}
#[test]
fn store_only_apps_take_only_the_listing_text_write() {
use storebound_desktop::policy::{store_only_write, STORE_ONLY_WRITES};
assert_eq!(STORE_ONLY_WRITES, &["listing.set"]);
assert!(store_only_write("listing.set", Some(Kind::Write)));
// the folder writes, owner actions and reads are not store-only writes
for m in ["play.listing", "apple.listing", "project.edit", "play.images"] {
assert!(!store_only_write(m, Some(Kind::Write)), "{m}");
}
assert!(!store_only_write("listing.set", Some(Kind::Owner)));
assert!(!store_only_write("listing.set", Some(Kind::Read)));
// a dry run from the window, like every write
let p = sanitize_js_call("listing.set", Some(Kind::Write), &json!({ "args": { "store": "play", "lang": "en-US", "fields": "{\"short\":\"x\"}" } })).unwrap();
assert_eq!(p["dryRun"], json!(true));
// the token binds the store ids instead of a folder: another app's ids give another token
let secret = [9u8; 32];
let a = json!({ "virtual": { "name": "com.example.a", "play": { "packageName": "com.example.a" } }, "args": { "lang": "en-US" } });
let b = json!({ "virtual": { "name": "com.example.b", "play": { "packageName": "com.example.b" } }, "args": { "lang": "en-US" } });
assert_ne!(mint(&secret, "listing.set", &a, 1_000, "n1").unwrap(), mint(&secret, "listing.set", &b, 1_000, "n1").unwrap());
}
#[test]
fn owner_dialogs_name_the_action() {
let (title, ok) = dialog_labels(Kind::Owner, "Change the Play staged rollout");
assert_eq!(title, "Owner action: Change the Play staged rollout");
assert_eq!(ok, "Yes: Change the Play staged rollout");
assert_eq!(dialog_labels(Kind::Write, "Push Play listing text"), ("Apply: Push Play listing text".to_string(), "Apply".to_string()));
let s = owner_summary("Send one approved reply", "Sends one approved, unsent reply draft.", "Prism Puddle");
assert!(s.contains("Prism Puddle") && s.contains("right away") && s.contains("your decision"));
assert!(dialog_summary("Push", "d", "A", true, false).contains("not public until you submit"));
}
#[test]
fn submit_for_review_is_an_owner_action_with_its_own_dialog() {
assert_eq!(SUBMIT_METHODS, &["owner.apple_submit", "owner.apple_resubmit", "owner.apple_iap_submit", "owner.play_production"]);
for m in SUBMIT_METHODS {
// a dry run from the window, whatever it sends; the real run only through write_with_confirm
let p = sanitize_js_call(m, Some(Kind::Owner), &json!({ "args": { "build": "52" } })).unwrap();
assert_eq!(p["dryRun"], json!(true));
for bad in [json!({ "dryRun": false }), json!({ "confirmToken": "sbt1.x" }), json!({ "args": {}, "project": "/x" })] {
assert!(sanitize_js_call(m, Some(Kind::Owner), &bad).is_err(), "{m} {bad}");
}
let s = summary_for(m, Kind::Owner, "Submit for App Review", "Sends the version.", "Prism Puddle", true, false);
assert!(s.contains("Prism Puddle") && s.contains("store's review") && s.contains("go live without another click") && s.contains("press the button"), "{s}");
}
// other owner actions keep their summary, writes theirs
assert!(summary_for("owner.play_rollout", Kind::Owner, "T", "d", "A", true, false).contains("right away"));
assert!(summary_for("play.listing", Kind::Write, "T", "d", "A", true, false).contains("not public until you submit"));
assert_eq!(dialog_labels(Kind::Owner, "Submit for App Review").1, "Yes: Submit for App Review");
}
#[test]
fn tokens_bind_method_project_and_args_of_each_write() {
let secret = [9u8; 32];
let writes = [
("play.listing", json!({})),
("apple.listing", json!({ "version": "2.4.0" })),
("apple.screenshots", json!({})),
("apple.testflight_distribute", json!({ "build": "52", "groups": "Studio team" })),
("reviews.reply", json!({ "file": "play-r-p1.md", "text": "Thanks!" })),
("owner.play_rollout", json!({ "action": "percent", "percent": 50 })),
("owner.ios_phased", json!({ "action": "resume", "version": "2.3.0" })),
("owner.reply_send", json!({ "file": "play-r-p1.md" })),
("owner.apple_submit", json!({ "build": "52", "version": "2.4.0" })),
("owner.apple_resubmit", json!({ "build": "53", "resolve_iaps": true })),
("owner.play_production", json!({ "version_code": "24", "status": "inProgress", "percent": 10 })),
];
for (method, args) in writes {
let bound = json!({ "project": "/apps/prism", "args": args });
let t = mint(&secret, method, &bound, 1_000, "AAAAAAAAAAAAAAAAAAAAAA").unwrap();
let sig = t.rsplit('.').next().unwrap().to_string();
assert_eq!(sig, mac(&secret, method, &bound, 61_000, "AAAAAAAAAAAAAAAAAAAAAA").unwrap());
// any change of method, project or args changes the signature
assert_ne!(sig, mac(&secret, "play.images", &bound, 61_000, "AAAAAAAAAAAAAAAAAAAAAA").unwrap(), "{method}");
assert_ne!(sig, mac(&secret, method, &json!({ "project": "/apps/other", "args": bound["args"] }), 61_000, "AAAAAAAAAAAAAAAAAAAAAA").unwrap());
let mut more = bound.clone();
more["args"]["x"] = json!(1);
assert_ne!(sig, mac(&secret, method, &more, 61_000, "AAAAAAAAAAAAAAAAAAAAAA").unwrap());
}
// a rollout share is a safe integer (percent), so it can be bound; fractions as numbers cannot
assert!(canonical(&json!({ "percent": 50 })).is_ok());
assert!(canonical(&json!({ "fraction": 0.5 })).is_err());
}
#[test]
fn console_fill_is_a_dry_run_from_the_window_and_its_dialog_lists_the_answers() {
let p = sanitize_js_call(CONSOLE_FILL, Some(Kind::Write), &json!({ "args": { "form": "apple-age-rating" } })).unwrap();
assert_eq!(p["dryRun"], json!(true));
assert!(sanitize_js_call(CONSOLE_FILL, Some(Kind::Write), &json!({ "dryRun": false })).is_err());
let data = json!({ "title": "Age rating", "uncertain": 1, "answers": [
{ "id": "violence", "label": "Cartoon violence", "value": "None", "certain": true, "source": "apple.ageRating" },
{ "id": "gambling", "label": "Simulated gambling", "value": "", "certain": false, "source": "" },
] });
let (title, text, ok) = console_fill_dialog("Prism Puddle", &data);
assert_eq!(title, "Fill in console: Age rating");
assert_eq!(ok, "Open the browser");
assert!(text.contains("Prism Puddle") && text.contains(CONSOLE_FILL_NOTE));
assert!(text.contains("uses storebound's own browser profile") && text.contains("cookies are never copied"));
assert!(text.contains("- Cartoon violence: None") && text.contains("- Simulated gambling: you answer this"));
let (_, empty, _) = console_fill_dialog("A", &json!(null));
assert!(empty.contains("every field is yours"));
let many: Vec<_> = (0..400).map(|i| json!({ "label": format!("Question {i}"), "value": "Yes", "certain": true })).collect();
let (_, long, _) = console_fill_dialog("A", &json!({ "title": "T", "answers": many }));
assert!(long.contains("more in the app") && long.chars().count() < 3_200);
}