mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 03:36:54 -06:00
Sheet protection stored an internal FNV hash that was never written to XLSX, and an Excel sheet password was dropped on import, so imported protected sheets could be unprotected without it. Protect Sheet now stores a salted SHA-512 hash (spin count 100000) via PasswordHash; the XLSX sheetProtection password (iterated or legacy) is read and written, and Unprotect Sheet really verifies it. Hashes from older GridCraft JSON workbooks are still read and verified. Refs #346 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
71 lines
2.6 KiB
Rust
71 lines
2.6 KiB
Rust
//! Protect Sheet / Unprotect Sheet mask the password as it's typed (issue #347).
|
|
|
|
use egui::accesskit::Role;
|
|
use egui_kittest::kittest::Queryable as _;
|
|
use gridcraft_engine::Session;
|
|
use gridcraft_ui_egui::SheetApp;
|
|
use serde_json::json;
|
|
|
|
const SECRET: &str = "s3cret";
|
|
|
|
fn harness() -> 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, Default::default()),
|
|
);
|
|
h.run_steps(4);
|
|
h
|
|
}
|
|
|
|
fn type_password(h: &mut egui_kittest::Harness<'static, SheetApp>) {
|
|
h.get_by_role(Role::PasswordInput).click();
|
|
h.run_steps(2);
|
|
h.input_mut().events.push(egui::Event::Text(SECRET.into()));
|
|
h.run_steps(2);
|
|
}
|
|
|
|
fn secret_on_screen(h: &egui_kittest::Harness<'static, SheetApp>) -> bool {
|
|
h.query_all_by(|n| n.value().is_some_and(|v| v.contains(SECRET)) || n.label().is_some_and(|l| l.contains(SECRET))).next().is_some()
|
|
}
|
|
|
|
#[test]
|
|
fn protect_and_unprotect_sheet_mask_the_password() {
|
|
let mut h = harness();
|
|
h.state_mut().open_dialog("protectSheet", json!({}));
|
|
h.run_steps(3);
|
|
type_password(&mut h);
|
|
assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("password")), Some(&json!(SECRET)));
|
|
assert!(!secret_on_screen(&h), "the typed password must not be readable");
|
|
assert!(h.get_by_role(Role::PasswordInput).value().is_some_and(|v| !v.is_empty() && !v.contains(SECRET)));
|
|
|
|
// Show password reveals it on purpose; unticking masks it again.
|
|
h.get_by_label("Show password").click();
|
|
h.run_steps(3);
|
|
assert!(h.query_by_role(Role::PasswordInput).is_none());
|
|
assert!(secret_on_screen(&h));
|
|
h.get_by_label("Show password").click();
|
|
h.run_steps(3);
|
|
assert!(h.query_by_role(Role::PasswordInput).is_some());
|
|
assert!(!secret_on_screen(&h));
|
|
|
|
h.get_by_label(" OK ").click();
|
|
h.run_steps(3);
|
|
assert!(h.state().dialog.is_none());
|
|
assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.as_ref().is_some_and(|p| p.password.is_some()));
|
|
|
|
// Unprotect asks for the password with a masked field too, and it starts hidden again.
|
|
h.state_mut().open_dialog("unprotectSheet", json!({}));
|
|
h.run_steps(3);
|
|
type_password(&mut h);
|
|
assert!(!secret_on_screen(&h), "the typed password must not be readable");
|
|
h.get_by_label(" OK ").click();
|
|
h.run_steps(3);
|
|
assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.is_none());
|
|
}
|