Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions src/components/results-grid/RowDetailSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {};
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 (
Expand Down
88 changes: 85 additions & 3 deletions tests/components/results-grid/RowDetailSheet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>([["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(
<RowDetailSheet
row={row}
fields={[
"id",
"score",
"active",
"name",
"middle_name",
"tags",
"profile",
"email",
"payload",
"count",
"omitted",
]}
isOpen
onClose={mock(() => {})}
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<string, unknown>([["email", { type: "email" }]]);
const { queryByText, queryByTitle } = render(
<RowDetailSheet
row={{ email: "alice@example.com" }}
fields={["email"]}
isOpen
onClose={mock(() => {})}
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<string, unknown>([["email", { type: "email" }]]);
const { queryByText, queryByTitle } = render(
Expand Down Expand Up @@ -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(
<RowDetailSheet
row={row}
Expand All @@ -438,7 +519,8 @@ describe("results-grid/RowDetailSheet", () => {
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
Expand Down Expand Up @@ -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]",
});
Expand Down