Files
gridcraft/crates/ui-egui/tests/protect_password.rs
T
vangel76andClaude Opus 5.5 22978a7e62 Review: Protect Sheet passwords use Excel-compatible hashes, kept in XLSX
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>
2026-10-11 21:29:10 +02:00

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());
}