Site builder: security & data-loss hardening + unified asset picker + audit backlog #3
@@ -0,0 +1,259 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import type { NodeTree, Node } from '@craftjs/core';
|
||||||
|
import { useKeyboardShortcuts } from './useKeyboardShortcuts';
|
||||||
|
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression coverage: Ctrl/Cmd+V must run the copied subtree through
|
||||||
|
* `regenerateTreeIds` BEFORE handing it to `actions.addNodeTree`. Pasting
|
||||||
|
* with the source node's ORIGINAL ids re-inserted them into the live Craft.js
|
||||||
|
* node map, producing duplicate ids and corrupting the tree -- that bug is
|
||||||
|
* exactly what this suite guards against. Also covers the sibling-parent /
|
||||||
|
* ROOT-fallback targeting, the empty-clipboard no-op, and the existing
|
||||||
|
* input-focus guard.
|
||||||
|
*
|
||||||
|
* Mock pattern mirrors PageContext.pure-updaters.test.tsx /
|
||||||
|
* PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`,
|
||||||
|
* mounted via a bare consumer component, with REAL `keydown` events
|
||||||
|
* dispatched on `document` (where useKeyboardShortcuts attaches its
|
||||||
|
* listener) to exercise the actual handler rather than calling it directly.
|
||||||
|
*
|
||||||
|
* `regenerateTreeIds` itself is NOT mocked -- we partially mock
|
||||||
|
* `../utils/craft-tree` to wrap the real implementation in a `vi.fn()` so we
|
||||||
|
* can assert it was called, while still getting genuinely fresh ids back.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const addNodeTreeMock = vi.fn();
|
||||||
|
let selectedIds: string[] = [];
|
||||||
|
|
||||||
|
function makeNode(id: string, parent: string | null, children: string[] = []): Node {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
data: {
|
||||||
|
props: {},
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
name: 'Container',
|
||||||
|
displayName: 'Container',
|
||||||
|
isCanvas: false,
|
||||||
|
parent,
|
||||||
|
linkedNodes: {},
|
||||||
|
nodes: children,
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
info: {},
|
||||||
|
events: { selected: false, dragged: false, hovered: false },
|
||||||
|
dom: null,
|
||||||
|
related: {},
|
||||||
|
rules: {},
|
||||||
|
_hydrationTimestamp: 0,
|
||||||
|
} as unknown as Node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tree `toNodeTree()` returns for the node that was Ctrl+C'd -- a root
|
||||||
|
// with one child, so regeneration has more than one id to remap.
|
||||||
|
const COPIED_TREE: NodeTree = {
|
||||||
|
rootNodeId: 'copied-root-1',
|
||||||
|
nodes: {
|
||||||
|
'copied-root-1': makeNode('copied-root-1', null, ['copied-child-1']),
|
||||||
|
'copied-child-1': makeNode('copied-child-1', 'copied-root-1'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeStore: Record<string, { data: { parent: string | null } }> = {
|
||||||
|
'selected-1': { data: { parent: 'parent-container-1' } },
|
||||||
|
ROOT: { data: { parent: null } },
|
||||||
|
'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeQueryNode(id: string) {
|
||||||
|
return {
|
||||||
|
get: () => nodeStore[id] ?? null,
|
||||||
|
toNodeTree: () => {
|
||||||
|
if (id !== 'copied-root-1') {
|
||||||
|
throw new Error(`unexpected toNodeTree() call for "${id}"`);
|
||||||
|
}
|
||||||
|
return COPIED_TREE;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: () => ({
|
||||||
|
query: {
|
||||||
|
getEvent: () => ({ all: () => selectedIds }),
|
||||||
|
node: (id: string) => makeQueryNode(id),
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
addNodeTree: addNodeTreeMock,
|
||||||
|
history: { undo: vi.fn(), redo: vi.fn() },
|
||||||
|
delete: vi.fn(),
|
||||||
|
clearEvents: vi.fn(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Partial mock: keep the real `regenerateTreeIds` implementation (so pasted
|
||||||
|
// trees genuinely get fresh ids) but wrap it in a spy so we can assert the
|
||||||
|
// handler actually calls it, rather than only inferring that from the
|
||||||
|
// output.
|
||||||
|
vi.mock('../utils/craft-tree', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../utils/craft-tree')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
regenerateTreeIds: vi.fn(actual.regenerateTreeIds),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { regenerateTreeIds } from '../utils/craft-tree';
|
||||||
|
const regenerateTreeIdsMock = vi.mocked(regenerateTreeIds);
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render(ui: React.ReactElement) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmount() {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pressKey(key: string, opts: Partial<KeyboardEventInit> = {}) {
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key, ctrlKey: true, bubbles: true, cancelable: true, ...opts }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
useKeyboardShortcuts();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
selectedIds = [];
|
||||||
|
addNodeTreeMock.mockClear();
|
||||||
|
regenerateTreeIdsMock.mockClear();
|
||||||
|
setClipboardNodeId(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setClipboardNodeId(null);
|
||||||
|
if (root) unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
||||||
|
test('Ctrl+C copies the selected node id to the clipboard', () => {
|
||||||
|
render(<Consumer />);
|
||||||
|
selectedIds = ['selected-1'];
|
||||||
|
|
||||||
|
pressKey('c');
|
||||||
|
|
||||||
|
expect(getClipboardNodeId()).toBe('selected-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ctrl+V pastes as a sibling of the selection (selected node`s data.parent) with FRESH ids', () => {
|
||||||
|
render(<Consumer />);
|
||||||
|
|
||||||
|
selectedIds = ['copied-root-1'];
|
||||||
|
pressKey('c');
|
||||||
|
expect(getClipboardNodeId()).toBe('copied-root-1');
|
||||||
|
|
||||||
|
selectedIds = ['selected-1'];
|
||||||
|
pressKey('v');
|
||||||
|
|
||||||
|
// regenerateTreeIds actually ran before the tree was handed to Craft.js.
|
||||||
|
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(regenerateTreeIdsMock).toHaveBeenCalledWith(COPIED_TREE);
|
||||||
|
|
||||||
|
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [pastedTree, targetParent] = addNodeTreeMock.mock.calls[0];
|
||||||
|
|
||||||
|
// Sibling of the current selection: selected-1's data.parent.
|
||||||
|
expect(targetParent).toBe('parent-container-1');
|
||||||
|
|
||||||
|
// The regression this guards: pasted ids must be fresh, never reuse the
|
||||||
|
// ids the copied node already occupies in the live Craft.js tree.
|
||||||
|
expect(pastedTree.rootNodeId).not.toBe(COPIED_TREE.rootNodeId);
|
||||||
|
const originalIds = new Set(Object.keys(COPIED_TREE.nodes));
|
||||||
|
const pastedIds = new Set(Object.keys(pastedTree.nodes));
|
||||||
|
expect(pastedIds.size).toBe(originalIds.size);
|
||||||
|
for (const id of pastedIds) {
|
||||||
|
expect(originalIds.has(id)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ctrl+V with selection at ROOT falls back to ROOT as the insertion parent', () => {
|
||||||
|
render(<Consumer />);
|
||||||
|
|
||||||
|
selectedIds = ['copied-root-1'];
|
||||||
|
pressKey('c');
|
||||||
|
|
||||||
|
selectedIds = ['ROOT'];
|
||||||
|
pressKey('v');
|
||||||
|
|
||||||
|
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [, targetParent] = addNodeTreeMock.mock.calls[0];
|
||||||
|
expect(targetParent).toBe('ROOT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ctrl+V with an empty clipboard is a no-op', () => {
|
||||||
|
render(<Consumer />);
|
||||||
|
selectedIds = ['selected-1'];
|
||||||
|
|
||||||
|
pressKey('v');
|
||||||
|
|
||||||
|
expect(addNodeTreeMock).not.toHaveBeenCalled();
|
||||||
|
expect(regenerateTreeIdsMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shortcuts are ignored while focus is in an input element', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
|
||||||
|
render(<Consumer />);
|
||||||
|
selectedIds = ['selected-1'];
|
||||||
|
|
||||||
|
pressKey('c');
|
||||||
|
expect(getClipboardNodeId()).toBeNull();
|
||||||
|
|
||||||
|
setClipboardNodeId('copied-root-1');
|
||||||
|
pressKey('v');
|
||||||
|
expect(addNodeTreeMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shortcuts are ignored while focus is in a contentEditable element', () => {
|
||||||
|
// jsdom does not implement `HTMLElement.isContentEditable` (it always
|
||||||
|
// reports false regardless of the contentEditable attribute -- a known
|
||||||
|
// jsdom limitation), so a real contentEditable + focus() can't exercise
|
||||||
|
// this branch of the guard. Stub `document.activeElement` directly with
|
||||||
|
// a fake element that reports isContentEditable: true, matching what
|
||||||
|
// the guard (`isInputFocused` in useKeyboardShortcuts.ts) actually reads.
|
||||||
|
const fakeEditable = { tagName: 'DIV', isContentEditable: true } as unknown as Element;
|
||||||
|
const activeElementSpy = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(fakeEditable);
|
||||||
|
|
||||||
|
render(<Consumer />);
|
||||||
|
selectedIds = ['selected-1'];
|
||||||
|
|
||||||
|
pressKey('c');
|
||||||
|
expect(getClipboardNodeId()).toBeNull();
|
||||||
|
|
||||||
|
activeElementSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user