storebound/apps/desktop/src-tauri/tests/d3_writes.rs
CRT 0035931734 console fill: never copy Google session cookies by default (device-bound); own profile in a Chrome started without automation switches
Google binds its session cookies to the device (Device Bound Session Credentials) and signs out
sessions whose cookies look stolen, which can sign the owner out of their real Chrome. The default is
now storebound's own profile per store; storebound starts Chrome itself (--user-data-dir,
--remote-debugging-port=0, no --enable-automation, navigator.webdriver false) and attaches over CDP.
--use-chrome-session google|apple is an explicit opt-in that warns. /signin/rejected is said plainly.
2026-10-12 07:46:38 +08:00

82 lines
5.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};
use storebound_desktop::confirm::{canonical, mac, mint};
use storebound_desktop::policy::{sanitize_js_call, Kind, CONSOLE_FILL, CONSOLE_FILL_NOTE};
#[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());
}
#[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);
}