diff --git a/src/components/results-grid/RowDetailSheet.tsx b/src/components/results-grid/RowDetailSheet.tsx index 64e9a5126f..752b464962 100644 --- a/src/components/results-grid/RowDetailSheet.tsx +++ b/src/components/results-grid/RowDetailSheet.tsx @@ -107,18 +107,23 @@ export function RowDetailSheet({ }; const copyAllAsJson = () => { - // If masking is active, copy masked version - if (maskingActive && sensitiveColumns && sensitiveColumns.size > 0) { - const maskedRow: Record = {}; - for (const field of fields) { - maskedRow[field] = copyTextOf(field, row[field]); - } - copyAndReport("__all__", JSON.stringify(maskedRow, null, 2)); - } else { - // The masked branch above copies each field's display text, which is already the - // hex for a binary field; this one has to say so itself (#1381). - copyAndReport("__all__", jsonText(binaryCellsAsHex(row), 2)); - } + // Keep raw JSON values for unmasked fields; display text would stringify their types. + const copiedRow = Object.fromEntries( + fields.map((field) => { + const value = Object.hasOwn(row, field) ? row[field] : undefined; + const pattern = sensitiveColumns?.get(field); + let copiedValue = value; + if (maskingActive && pattern && value != null) { + copiedValue = maskValueByPattern(value, pattern); + } else if (maskingActive && sensitiveColumns && sensitiveColumns.size > 0 && vectorColumns?.[field]) { + // Declared vectors keep their established renderer copy form under masking. + const context = renderContextFor(vectorColumns, field); + copiedValue = getRenderer(classifyValue(value, context)).renderCopy?.(value, context) ?? value; + } + return [field, copiedValue] as const; + }), + ); + copyAndReport("__all__", jsonText(binaryCellsAsHex(copiedRow), 2)); }; return ( diff --git a/tests/components/results-grid/RowDetailSheet.test.tsx b/tests/components/results-grid/RowDetailSheet.test.tsx index e26e99f0ab..14d18083e6 100644 --- a/tests/components/results-grid/RowDetailSheet.test.tsx +++ b/tests/components/results-grid/RowDetailSheet.test.tsx @@ -110,6 +110,87 @@ describe("results-grid/RowDetailSheet", () => { expect(String(writeText.mock.calls[0]?.[0])).toContain("***MASKED***"); }); + test("Copy JSON preserves unmasked types and masks sensitive fields", () => { + const sensitiveColumns = new Map([["email", { type: "email" }]]); + const row = { + id: 12, + score: 98.5, + active: true, + name: "Alex", + middle_name: null, + tags: ["admin", 3], + profile: { verified: true }, + email: "alice@example.com", + payload: { type: "Buffer", data: [0xde, 0xad] }, + count: BigInt("9007199254740993"), + omitted: undefined, + excluded: "not in fields", + }; + const { queryByText } = render( + {})} + rowIndex={0} + maskingActive + sensitiveColumns={sensitiveColumns as never} + />, + ); + + fireEvent.click(queryByText("Copy JSON")!); + const copiedText = String(writeText.mock.calls[0]?.[0]); + expect(JSON.parse(copiedText)).toEqual({ + id: 12, + score: 98.5, + active: true, + name: "Alex", + middle_name: null, + tags: ["admin", 3], + profile: { verified: true }, + email: "***MASKED***", + payload: "\\xdead", + count: "9007199254740993", + }); + expect(copiedText).not.toContain("alice@example.com"); + expect(copiedText).not.toContain("not in fields"); + }); + + test("Copy JSON keeps a revealed sensitive value masked", () => { + const sensitiveColumns = new Map([["email", { type: "email" }]]); + const { queryByText, queryByTitle } = render( + {})} + rowIndex={0} + maskingActive + sensitiveColumns={sensitiveColumns as never} + allowReveal + />, + ); + + fireEvent.click(queryByTitle("Reveal value (10s)")!); + fireEvent.click(queryByText("Copy JSON")!); + + const copiedText = String(writeText.mock.calls[0]?.[0]); + expect(JSON.parse(copiedText)).toEqual({ email: "***MASKED***" }); + expect(copiedText).not.toContain("alice@example.com"); + }); + test("shows masked value and reveals when reveal button is clicked", () => { const sensitiveColumns = new Map([["email", { type: "email" }]]); const { queryByText, queryByTitle } = render( @@ -423,7 +504,7 @@ describe("results-grid/RowDetailSheet", () => { }); test("raw JSON copy contains exact row data when no masking active", () => { - const row = { id: 42, name: "Charlie", active: true, score: 99.5 }; + const row = { id: 42, name: "Charlie", active: true, score: 99.5, excluded: "not in fields" }; const { queryByText } = render( { expect(writeText).toHaveBeenCalledTimes(1); const copiedJson = String(writeText.mock.calls[0]?.[0]); const parsed = JSON.parse(copiedJson); - expect(parsed).toEqual(row); + expect(parsed).toEqual({ id: 42, name: "Charlie", active: true, score: 99.5 }); + expect(copiedJson).not.toContain("not in fields"); }); // The sheet shows a binary field as `\x` hex, and Copy JSON used to copy the Buffer @@ -587,7 +669,7 @@ describe("results-grid/RowDetailSheet", () => { fireEvent.click(queryByText("Copy JSON")!); expect(writeText).toHaveBeenCalledTimes(1); expect(JSON.parse(String(writeText.mock.calls[0]?.[0]))).toEqual({ - id: "1", + id: 1, email: "***MASKED***", embedding: "[1.0,0.5,0.0]", });