UI: ask for the password of an encrypted workbook (#11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
aad58d4c75
commit
c882d169ff
|
|
@ -1619,6 +1619,7 @@ dependencies = [
|
|||
"epaint_default_fonts",
|
||||
"gridcraft-chart",
|
||||
"gridcraft-engine",
|
||||
"gridcraft-xlsx",
|
||||
"image",
|
||||
"log",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ zbus = "=5.19.0"
|
|||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
epaint_default_fonts = { workspace = true }
|
||||
gridcraft-xlsx = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# The desktop's preferred language for the first-run interface language.
|
||||
|
|
|
|||
|
|
@ -236,6 +236,19 @@ impl Dialog {
|
|||
vec![Password { key: "password", label: "Password:" }],
|
||||
json!({"on": false}),
|
||||
),
|
||||
"openPassword" => {
|
||||
// The file rides along in the values (`path` on the desktop, `name` and `base64`
|
||||
// on the web) and goes back to `file.open` with the password.
|
||||
let source = p("path").as_str().or(p("name").as_str()).map(str::to_string).unwrap_or_default();
|
||||
let file = std::path::Path::new(&source).file_name().and_then(|n| n.to_str()).unwrap_or("This workbook").to_string();
|
||||
Dialog::form(
|
||||
"openPassword",
|
||||
"Password",
|
||||
"file.open",
|
||||
vec![Note(format!("'{file}' is protected.")), Password { key: "password", label: "Password:" }],
|
||||
params.clone(),
|
||||
)
|
||||
}
|
||||
"unprotectSheet" => Dialog::form(
|
||||
"unprotectSheet",
|
||||
"Unprotect Sheet",
|
||||
|
|
@ -1101,12 +1114,20 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
|||
open = false;
|
||||
if d.name == "solver" {
|
||||
app.open_dialog("solverResults", r);
|
||||
} else if d.name == "openPassword" {
|
||||
let path = d.values.get("path").and_then(Json::as_str).map(str::to_string);
|
||||
app.after_open(path.as_deref(), &r);
|
||||
}
|
||||
}
|
||||
// Data Analysis asks before replacing data in its output range.
|
||||
Err(e) if d.name == "analysisToolPak" && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => {
|
||||
d.values.insert("askOverwrite".into(), json!(true));
|
||||
}
|
||||
// A wrong password: say so, clear the field and stay open for another try.
|
||||
Err(e) if d.name == "openPassword" => {
|
||||
d.values.insert("password".into(), json!(""));
|
||||
app.message = Some((d.title.clone(), crate::clean_error(&e)));
|
||||
}
|
||||
Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))),
|
||||
}
|
||||
} else if confirm {
|
||||
|
|
|
|||
|
|
@ -624,17 +624,25 @@ impl SheetApp {
|
|||
|
||||
pub fn open_path(&mut self, path: &str) {
|
||||
match self.run("file.open", json!({"path": path})) {
|
||||
Ok(r) => {
|
||||
self.ui.recent.retain(|p| p != path);
|
||||
self.ui.recent.insert(0, path.to_string());
|
||||
self.ui.recent.truncate(20);
|
||||
self.show_open_warnings(&r);
|
||||
}
|
||||
Ok(r) => self.after_open(Some(path), &r),
|
||||
// A password-protected workbook: ask for the password; the dialog opens it.
|
||||
Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => self.open_dialog("openPassword", json!({"path": path})),
|
||||
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
|
||||
}
|
||||
self.after_engine();
|
||||
}
|
||||
|
||||
/// What follows a successful `file.open`: the Recent list (for a file on disk) and the open
|
||||
/// warnings notice. Shared by File › Open and the password dialog.
|
||||
pub fn after_open(&mut self, path: Option<&str>, r: &Json) {
|
||||
if let Some(path) = path {
|
||||
self.ui.recent.retain(|p| p != path);
|
||||
self.ui.recent.insert(0, path.to_string());
|
||||
self.ui.recent.truncate(20);
|
||||
}
|
||||
self.show_open_warnings(r);
|
||||
}
|
||||
|
||||
/// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the
|
||||
/// most important one (a preserved VBA project outranks, say, a skipped optional part).
|
||||
/// Kept deliberately generic: any open-time warning can reach the user this way.
|
||||
|
|
@ -893,12 +901,16 @@ impl SheetApp {
|
|||
.unwrap_or_default();
|
||||
for (name, bytes) in arrived {
|
||||
let b64 = gridcraft_engine::io::base64_encode(&bytes);
|
||||
match self.run("file.open", json!({"name": name, "base64": b64})) {
|
||||
match self.run("file.open", json!({"name": &name, "base64": &b64})) {
|
||||
// A file picked from File › Open (web) arrives here: show it, not the Backstage.
|
||||
Ok(r) => {
|
||||
self.backstage = None;
|
||||
self.show_open_warnings(&r);
|
||||
}
|
||||
Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => {
|
||||
self.backstage = None;
|
||||
self.open_dialog("openPassword", json!({"name": name, "base64": b64}));
|
||||
}
|
||||
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
|
||||
}
|
||||
self.after_engine();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
//! Opening a password-protected workbook asks for its password (#11).
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::{Session, core::CellRef, core::Value};
|
||||
use gridcraft_ui_egui::{Inbox, Services, SheetApp};
|
||||
use serde_json::json;
|
||||
|
||||
const PW: &str = "synthetic-pw-8842";
|
||||
|
||||
fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, services),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// A workbook with 42 in A1, saved as `.xlsx` and encrypted with `PW`.
|
||||
fn encrypted_bytes() -> Vec<u8> {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.run("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();
|
||||
let xlsx = gridcraft_engine::io::save_bytes(&s.active().unwrap().wb, "book.xlsx").unwrap();
|
||||
gridcraft_xlsx::testing::encrypt_workbook(&xlsx, PW)
|
||||
}
|
||||
|
||||
/// `encrypted_bytes()` written to `Secret.xlsx` in a fresh temp dir named after `tag`.
|
||||
fn encrypted_file(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-encrypted-ui-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Secret.xlsx");
|
||||
std::fs::write(&path, encrypted_bytes()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn dialog(h: &egui_kittest::Harness<'static, SheetApp>) -> Option<String> {
|
||||
h.state().dialog.as_ref().map(|d| d.name().to_string())
|
||||
}
|
||||
|
||||
fn a1(h: &egui_kittest::Harness<'static, SheetApp>) -> Value {
|
||||
h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("A1").unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_an_encrypted_file_asks_for_its_password() {
|
||||
let path = encrypted_file("desktop");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let mut h = harness(Services::default());
|
||||
h.state_mut().open_path(&p);
|
||||
h.run_steps(3);
|
||||
assert_eq!(dialog(&h).as_deref(), Some("openPassword"));
|
||||
assert!(h.state().message.is_none(), "no error box: the dialog asks instead");
|
||||
assert!(h.query_by_role(Role::PasswordInput).is_some(), "the password is masked");
|
||||
|
||||
// A wrong password says so, clears the field, and keeps the dialog and its file.
|
||||
h.get_by_role(Role::PasswordInput).click();
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(egui::Event::Text("wrong-guess".into()));
|
||||
h.run_steps(2);
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
let msg = h.state().message.as_ref().map(|(_, m)| m.clone()).unwrap_or_default();
|
||||
assert!(msg.contains(gridcraft_engine::io::WRONG_PASSWORD), "{msg}");
|
||||
assert!(!msg.contains("wrong-guess"), "the password is never quoted: {msg}");
|
||||
assert_eq!(dialog(&h).as_deref(), Some("openPassword"));
|
||||
assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("password")), Some(&json!("")));
|
||||
assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("path")), Some(&json!(p)));
|
||||
h.state_mut().message = None;
|
||||
|
||||
// The right one opens the workbook: no save path, in Recent, with the notice.
|
||||
h.state_mut().dialog.as_mut().unwrap().values.insert("password".into(), json!(PW));
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(dialog(&h), None);
|
||||
assert_eq!(a1(&h), Value::Number(42.0));
|
||||
assert_eq!(h.state().session.active().unwrap().path, None);
|
||||
assert_eq!(h.state().ui.recent.first(), Some(&p));
|
||||
assert!(h.state().notice.as_ref().is_some_and(|n| n.text.contains("password-protected")));
|
||||
std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_leaves_nothing_open() {
|
||||
let path = encrypted_file("cancel");
|
||||
let mut h = harness(Services::default());
|
||||
let before = h.state().session.documents().len();
|
||||
h.state_mut().open_path(&path.to_string_lossy());
|
||||
h.run_steps(3);
|
||||
assert_eq!(dialog(&h).as_deref(), Some("openPassword"));
|
||||
h.get_by_label(" Cancel ").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(dialog(&h), None);
|
||||
assert_eq!(h.state().session.documents().len(), before);
|
||||
assert!(h.state().message.is_none());
|
||||
std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_picked_on_the_web_asks_for_its_password_too() {
|
||||
let inbox: Inbox = Default::default();
|
||||
let mut h = harness(Services { inbox: Some(inbox.clone()), ..Default::default() });
|
||||
inbox.lock().unwrap().push(("Secret.xlsx".into(), encrypted_bytes()));
|
||||
h.run_steps(3);
|
||||
assert_eq!(dialog(&h).as_deref(), Some("openPassword"));
|
||||
assert!(h.state().dialog.as_ref().is_some_and(|d| d.values.contains_key("base64")));
|
||||
h.state_mut().dialog.as_mut().unwrap().values.insert("password".into(), json!(PW));
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(dialog(&h), None);
|
||||
assert_eq!(a1(&h), Value::Number(42.0));
|
||||
assert_eq!(h.state().session.active().unwrap().path, None);
|
||||
}
|
||||
Loading…
Reference in New Issue