//! 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::{dialog_labels, dialog_summary, owner_summary}; use storebound_desktop::confirm::{canonical, mac, mint}; use storebound_desktop::policy::{sanitize_js_call, Kind}; #[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 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 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" })), ]; 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()); }