v0.3.8-pre.00-fix.001

This commit is contained in:
2026-09-03 18:45:53 +02:00
parent d5b4aef194
commit 32577ea48d
8 changed files with 291 additions and 17 deletions

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-store-desk/frontend/sass/_app.scss
// version: 1
// version: 2
$app-header-height: 72px;
$app-footer-height: 42px;
@@ -90,3 +90,28 @@ body {
.app-store-table-container {
min-height: 240px;
}
.app-copyable-long-text {
align-items: center;
display: inline-flex;
gap: .375rem;
max-width: 100%;
min-width: 0;
}
.app-copyable-long-text__value {
cursor: help;
display: inline-block;
max-width: 24ch;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
.app-copyable-long-text__copy {
flex: 0 0 auto;
line-height: 1;
padding: .25rem .375rem;
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-store-desk/frontend/ts/main.ts
// version: 4
// version: 5
import DataTable from "datatables.net-bs5";
import "datatables.net-bs5/css/dataTables.bootstrap5.css";
@@ -50,6 +50,102 @@ const viewTitles: Record<ViewId, string> = {
let transactionTable: StoreDataTable | null = null;
let accountTable: StoreDataTable | null = null;
const COPY_FEEDBACK_MILLISECONDS = 1200;
const LONG_TEXT_HEAD_CHARACTERS = 12;
const LONG_TEXT_TAIL_CHARACTERS = 8;
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function truncateLongText(value: string): string {
const threshold = LONG_TEXT_HEAD_CHARACTERS + LONG_TEXT_TAIL_CHARACTERS + 1;
if (value.length <= threshold) {
return value;
}
return `${value.slice(0, LONG_TEXT_HEAD_CHARACTERS)}${value.slice(-LONG_TEXT_TAIL_CHARACTERS)}`;
}
function renderCopyableLongText(value: string, fieldId: string): string {
const escapedValue = escapeHtml(value);
const escapedVisible = escapeHtml(truncateLongText(value));
const escapedFieldId = escapeHtml(fieldId);
return `<span class="app-copyable-long-text"><span class="app-copyable-long-text__value font-monospace" title="${escapedValue}">${escapedVisible}</span><button class="btn btn-sm btn-outline-secondary app-copyable-long-text__copy" type="button" data-copy-long-text="${escapedValue}" data-copy-field="${escapedFieldId}" title="Copier la valeur complète" aria-label="Copier la valeur complète"><i class="fa-regular fa-copy" aria-hidden="true"></i></button></span>`;
}
function setCopyableLongText(elementId: string, value: string, fieldId: string): void {
const element = document.querySelector<HTMLElement>(`#${elementId}`);
if (!element) {
return;
}
element.replaceChildren();
const wrapper = document.createElement("span");
wrapper.className = "app-copyable-long-text";
const text = document.createElement("span");
text.className = "app-copyable-long-text__value font-monospace";
text.textContent = truncateLongText(value);
text.title = value;
const button = document.createElement("button");
button.className = "btn btn-sm btn-outline-secondary app-copyable-long-text__copy";
button.type = "button";
button.dataset.copyLongText = value;
button.dataset.copyField = fieldId;
button.title = "Copier la valeur complète";
button.setAttribute("aria-label", "Copier la valeur complète");
button.innerHTML = '<i class="fa-regular fa-copy" aria-hidden="true"></i>';
wrapper.append(text, button);
element.append(wrapper);
}
async function writeClipboardText(value: string): Promise<boolean> {
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
try {
await navigator.clipboard.writeText(value);
return true;
} catch {
// Fall back to the document copy command for WebViews without Clipboard API permission.
}
}
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.left = "-10000px";
textarea.style.top = "-10000px";
document.body.append(textarea);
textarea.select();
const copied = document.execCommand("copy");
textarea.remove();
return copied;
}
async function copyLongText(button: HTMLButtonElement, value: string, fieldId: string): Promise<void> {
frontendDebug("main", "Store Desk long text copy requested", { fieldId });
const copied = await writeClipboardText(value);
if (!copied) {
frontendError("main", "Store Desk long text copy failed", { fieldId });
return;
}
const icon = button.querySelector<HTMLElement>("i");
const previousClassName = icon?.className ?? "";
if (icon) {
icon.className = "fa-solid fa-check";
}
button.title = "Copié";
frontendDebug("main", "Store Desk long text copy completed", { fieldId });
window.setTimeout(() => {
if (icon) {
icon.className = previousClassName;
}
button.title = "Copier la valeur complète";
}, COPY_FEEDBACK_MILLISECONDS);
}
function isViewId(value: string | undefined): value is ViewId {
return value === "overview" || value === "transactions" || value === "accounts" || value === "diagnostics";
}
@@ -187,20 +283,20 @@ function initializeTransactionTable(): void {
},
autoWidth: false,
columns: [
{ className: "font-monospace", data: "signature" },
{ data: "signature", render: (data, type) => (type === "display" ? renderCopyableLongText(String(data), "transaction-signature") : String(data)) },
{ className: "font-monospace", data: "slotDecimal" },
{ data: "blockTimeUnixMillisDecimal", defaultContent: "—" },
{ data: "formatId" },
{ data: "formatVersion" },
{ data: "payloadSizeDecimal", defaultContent: "—" },
{ className: "font-monospace", data: "contentHash" },
{ data: "contentHash", render: (data, type) => (type === "display" ? renderCopyableLongText(String(data), "transaction-content-hash") : String(data)) },
{ data: "retentionState", render: data => retentionBadge(String(data)) },
{
data: null,
defaultContent: "",
render: (_data, _type, row) => {
const transaction = row as StoreTransactionRowDto;
return `<button class="btn btn-sm btn-outline-primary" type="button" data-transaction-detail="${transaction.signature}"><i class="fa-solid fa-magnifying-glass me-1" aria-hidden="true"></i>Détail</button>`;
return `<button class="btn btn-sm btn-outline-primary" type="button" data-transaction-detail="${escapeHtml(transaction.signature)}"><i class="fa-solid fa-magnifying-glass me-1" aria-hidden="true"></i>Détail</button>`;
},
},
],
@@ -335,11 +431,11 @@ function clearTransactionDetail(): void {
}
function renderTransactionDetail(detail: StoreTransactionDetailDto): void {
setText("transactionDetailSignature", detail.signature);
setCopyableLongText("transactionDetailSignature", detail.signature, "transaction-detail-signature");
setText("transactionDetailSlot", detail.slotDecimal);
setText("transactionDetailBlockTime", detail.blockTimeUnixMillisDecimal ?? "—");
setText("transactionDetailFormat", `${detail.formatId} v${detail.formatVersion}`);
setText("transactionDetailContentHash", detail.contentHash);
setCopyableLongText("transactionDetailContentHash", detail.contentHash, "transaction-detail-content-hash");
setText("transactionDetailRetention", detail.retentionState);
setText("transactionDetailPayloadSize", detail.payloadSizeDecimal ?? "—");
setText("transactionDetailPayloadPreview", detail.payloadPreviewHex ?? "Payload non disponible dans cet état de rétention.");
@@ -352,7 +448,7 @@ async function openTransactionDetail(signature: string): Promise<void> {
return;
}
clearTransactionDetail();
setText("transactionDetailSignature", signature);
setCopyableLongText("transactionDetailSignature", signature, "transaction-detail-signature");
frontendDebug("main", "Store Desk transaction detail load requested");
const modal = Modal.getOrCreateInstance(modalElement);
modal.show();
@@ -410,6 +506,15 @@ function installInteractions(): void {
if (!(eventTarget instanceof Element)) {
return;
}
const copyButton = eventTarget.closest<HTMLButtonElement>("[data-copy-long-text]");
if (copyButton) {
const value = copyButton.dataset.copyLongText;
const fieldId = copyButton.dataset.copyField ?? "long-text";
if (value) {
void copyLongText(copyButton, value, fieldId);
}
return;
}
const detailButton = eventTarget.closest<HTMLElement>("[data-transaction-detail]");
if (detailButton) {
const signature = detailButton.dataset.transactionDetail;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-store-desk/tests/desktop_contract.rs
// version: 4
// version: 5
//! Desktop template and frontend contract tests for the Store Desk scaffold.
@@ -88,8 +88,7 @@ fn pre_007_shell_exposes_transaction_filters_detail_and_keeps_account_skeleton_u
}
assert!(html.contains("id=\"rawTransactionsTable\""));
assert!(html.contains("id=\"rawAccountsTable\""));
for overview_id in ["refreshOverview", "overviewStoreProfile", "overviewStoreTarget", "overviewStoreHealth", "overviewStorePool", "overviewStoreMigration"]
{
for overview_id in ["refreshOverview", "overviewStoreProfile", "overviewStoreTarget", "overviewStoreHealth", "overviewStorePool", "overviewStoreMigration"] {
assert!(html.contains(&format!("id=\"{overview_id}\"")), "missing Store Overview field: {overview_id}");
}
for transaction_id in ["refreshTransactions", "transactionSlotMin", "transactionSlotMax", "transactionDirection", "transactionDetailModal"] {
@@ -197,3 +196,20 @@ fn pre_007_transaction_ipc_maps_datatables_offset_limit_counts_and_detail_withou
assert!(!dto.contains("RawPageCursor"));
assert!(!frontend.contains("storePager"));
}
#[test]
fn pre_007_fix_001_long_transaction_identifiers_are_truncated_tooltipped_and_copyable() {
let frontend = include_str!("../frontend/ts/main.ts");
let sass = include_str!("../frontend/sass/_app.scss");
assert!(frontend.contains("truncateLongText"));
assert!(frontend.contains("renderCopyableLongText(String(data), "transaction-signature")"));
assert!(frontend.contains("renderCopyableLongText(String(data), "transaction-content-hash")"));
assert!(frontend.contains("setCopyableLongText("transactionDetailSignature""));
assert!(frontend.contains("setCopyableLongText("transactionDetailContentHash""));
assert!(frontend.contains("title="${escapedValue}""));
assert!(frontend.contains("data-copy-long-text"));
assert!(frontend.contains("navigator.clipboard.writeText"));
assert!(frontend.contains("document.execCommand("copy")"));
assert!(sass.contains(".app-copyable-long-text__value"));
assert!(sass.contains("text-overflow: ellipsis"));
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-store-desk/tests/desktop_security.rs
// version: 5
// version: 6
//! Security boundary tests for the Store Desk scaffold.
@@ -128,3 +128,22 @@ fn pre_007_transaction_request_rejects_datatables_search_order_draw_and_physical
assert!(runtime.contains("\"ascending\" =>"));
assert!(runtime.contains("\"descending\" =>"));
}
#[test]
fn pre_007_fix_001_copy_tracing_never_logs_long_identifier_values() {
let frontend = include_str!("../frontend/ts/main.ts");
assert!(frontend.contains("Store Desk long text copy requested"));
assert!(frontend.contains("Store Desk long text copy completed"));
assert!(frontend.contains("Store Desk long text copy failed"));
assert!(frontend.contains("{ fieldId }"));
for forbidden in [
"copy requested", { value",
"copy completed", { value",
"copy failed", { value",
"copy requested", { signature",
"copy completed", { signature",
"copy failed", { signature",
] {
assert!(!frontend.contains(forbidden), "long identifier value leaked into copy tracing: {forbidden}");
}
}