Site builder: security & data-loss hardening + unified asset picker + audit backlog #3

Merged
jknapp merged 61 commits from builder-hardening-2026-07 into main 2026-07-13 01:13:28 +00:00
2 changed files with 541 additions and 0 deletions
Showing only changes of commit c50b385661 - Show all commits
+204
View File
@@ -0,0 +1,204 @@
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 { AssetPicker, acceptFor, matchesMediaType, isStandalone } from './AssetPicker';
import type { Asset } from '../utils/assets';
vi.mock('../utils/assets', () => ({
uploadAsset: vi.fn(),
listAssets: vi.fn(),
}));
import { uploadAsset, listAssets } from '../utils/assets';
const uploadAssetMock = uploadAsset as unknown as ReturnType<typeof vi.fn>;
const listAssetsMock = listAssets as unknown as ReturnType<typeof vi.fn>;
const GRID_ASSETS: Asset[] = [
{ name: 'a.png', url: '/storage/assets/a.png', type: 'image/png' },
{ name: 'b.mp4', url: '/storage/assets/b.mp4', type: 'video/mp4' },
];
/* ---------- DOM test harness (no @testing-library/react in this repo, see
src/utils/assets.test.ts / navColorFields.test.ts for the plain-logic
pattern used elsewhere; component-level interaction here uses
react-dom/client + react-dom/test-utils `act`, both already transitive
deps of `react-dom`, so no new devDependency is introduced). ---------- */
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 click(el: Element | null) {
if (!el) throw new Error('element not found');
act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); });
}
function setValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function q<T extends Element = Element>(testId: string): T | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
describe('AssetPicker pure helpers', () => {
test('acceptFor maps mediaType to the file input accept attribute', () => {
expect(acceptFor('image')).toBe('image/*');
expect(acceptFor('video')).toBe('video/*');
expect(acceptFor('any')).toBeUndefined();
});
test('matchesMediaType guards drag-drop uploads by mediaType', () => {
expect(matchesMediaType('image/png', 'image')).toBe(true);
expect(matchesMediaType('video/mp4', 'image')).toBe(false);
expect(matchesMediaType('video/mp4', 'video')).toBe(true);
expect(matchesMediaType('application/pdf', 'any')).toBe(true);
});
test('isStandalone reflects presence of window.WHP_CONFIG', () => {
delete (window as any).WHP_CONFIG;
expect(isStandalone()).toBe(true);
(window as any).WHP_CONFIG = { apiUrl: 'x', siteId: 1, csrfToken: 't' };
expect(isStandalone()).toBe(false);
delete (window as any).WHP_CONFIG;
});
});
describe('AssetPicker component', () => {
beforeEach(() => {
uploadAssetMock.mockReset();
listAssetsMock.mockReset();
listAssetsMock.mockResolvedValue(GRID_ASSETS);
delete (window as any).WHP_CONFIG;
});
afterEach(() => {
if (container) unmount();
delete (window as any).WHP_CONFIG;
});
test('renders the current value as a preview thumbnail (full variant)', () => {
render(<AssetPicker value="/storage/assets/existing.png" onChange={vi.fn()} />);
const preview = q<HTMLImageElement>('asset-picker-preview');
expect(preview).not.toBeNull();
expect(preview!.src).toContain('/storage/assets/existing.png');
expect(q('asset-picker-dropzone')).toBeNull();
});
test('renders a dropzone (no preview) when value is empty', () => {
render(<AssetPicker value="" onChange={vi.fn()} />);
expect(q('asset-picker-dropzone')).not.toBeNull();
expect(q('asset-picker-preview')).toBeNull();
});
test('remove button clears the value via onChange("")', () => {
const onChange = vi.fn();
render(<AssetPicker value="/storage/assets/existing.png" onChange={onChange} />);
click(q('asset-picker-remove'));
expect(onChange).toHaveBeenCalledWith('');
});
test('Browse control is absent when window.WHP_CONFIG is undefined (standalone)', () => {
render(<AssetPicker value="" onChange={vi.fn()} />);
expect(q('asset-picker-browse')).toBeNull();
});
test('Browse control is present and lists assets filtered by mediaType when WHP_CONFIG is set', async () => {
(window as any).WHP_CONFIG = { apiUrl: 'https://x', siteId: 1, csrfToken: 't' };
render(<AssetPicker value="" onChange={vi.fn()} mediaType="video" />);
const browseBtn = q('asset-picker-browse');
expect(browseBtn).not.toBeNull();
await act(async () => { click(browseBtn); await Promise.resolve(); });
expect(listAssetsMock).toHaveBeenCalledWith('video');
expect(q('asset-picker-grid')).not.toBeNull();
});
test('selecting a grid asset invokes onChange with that asset url and closes the grid', async () => {
(window as any).WHP_CONFIG = { apiUrl: 'https://x', siteId: 1, csrfToken: 't' };
const onChange = vi.fn();
render(<AssetPicker value="" onChange={onChange} mediaType="any" />);
await act(async () => { click(q('asset-picker-browse')); await Promise.resolve(); });
expect(q('asset-picker-grid')).not.toBeNull();
click(q(`asset-picker-thumb-${GRID_ASSETS[0].name}`));
expect(onChange).toHaveBeenCalledWith(GRID_ASSETS[0].url);
expect(q('asset-picker-grid')).toBeNull();
});
test('URL apply invokes onChange with the typed url (full variant Apply button)', () => {
const onChange = vi.fn();
render(<AssetPicker value="" onChange={onChange} />);
const input = q<HTMLInputElement>('asset-picker-url-input')!;
setValue(input, 'https://example.com/pic.jpg');
click(q('asset-picker-apply'));
expect(onChange).toHaveBeenCalledWith('https://example.com/pic.jpg');
});
test('uploading a file calls uploadAsset and applies the resolved url via onChange', async () => {
uploadAssetMock.mockResolvedValue('/storage/assets/uploaded.png');
const onChange = vi.fn();
render(<AssetPicker value="" onChange={onChange} />);
const fileInput = q<HTMLInputElement>('asset-picker-file-input')!;
const file = new File(['x'], 'photo.png', { type: 'image/png' });
const dt = { files: [file] };
Object.defineProperty(fileInput, 'files', { value: dt.files });
await act(async () => {
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
});
expect(uploadAssetMock).toHaveBeenCalledWith(file);
expect(onChange).toHaveBeenCalledWith('/storage/assets/uploaded.png');
});
test('variant="compact" renders the compact single-row structure instead of the full structure', () => {
render(<AssetPicker value="" onChange={vi.fn()} variant="compact" />);
expect(q('asset-picker-compact')).not.toBeNull();
expect(q('asset-picker-full')).toBeNull();
expect(q('asset-picker-dropzone')).toBeNull();
expect(q('asset-picker-url-input')).not.toBeNull();
expect(q('asset-picker-upload')).not.toBeNull();
});
test('compact variant: URL input applies onChange on blur', () => {
const onChange = vi.fn();
render(<AssetPicker value="" onChange={onChange} variant="compact" />);
const input = q<HTMLInputElement>('asset-picker-url-input')!;
setValue(input, 'https://example.com/vid.mp4');
// React delegates onBlur via the bubbling 'focusout' event (blur itself doesn't bubble).
act(() => { input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); });
expect(onChange).toHaveBeenCalledWith('https://example.com/vid.mp4');
});
test('mediaType="video" sets file input accept to video/* and mediaType="any" omits accept', () => {
render(<AssetPicker value="" onChange={vi.fn()} mediaType="video" />);
expect(q<HTMLInputElement>('asset-picker-file-input')!.accept).toBe('video/*');
unmount();
render(<AssetPicker value="" onChange={vi.fn()} mediaType="any" />);
expect(q<HTMLInputElement>('asset-picker-file-input')!.accept).toBe('');
});
});
+337
View File
@@ -0,0 +1,337 @@
import React, { useState, useCallback, useRef, useEffect, CSSProperties } from 'react';
import { uploadAsset, listAssets, Asset } from '../utils/assets';
export interface AssetPickerProps {
value: string;
onChange: (url: string) => void;
/** Drives the file `<input accept>` and the `listAssets` grid filter. Default 'image'. */
mediaType?: 'image' | 'video' | 'any';
/** 'full' mirrors ImageStylePanel's source UI; 'compact' is a single tight row for array-editor cards. */
variant?: 'full' | 'compact';
placeholder?: string;
}
/* ---------- Pure helpers (exported for unit testing) ---------- */
/** File `<input accept>` value for a given mediaType; `undefined` (any file) for 'any'. */
export function acceptFor(mediaType: 'image' | 'video' | 'any'): string | undefined {
if (mediaType === 'image') return 'image/*';
if (mediaType === 'video') return 'video/*';
return undefined;
}
/** Whether a dropped/selected file's MIME type is acceptable for the given mediaType. */
export function matchesMediaType(fileType: string, mediaType: 'image' | 'video' | 'any'): boolean {
if (mediaType === 'any') return true;
return fileType.startsWith(mediaType);
}
/** True when running outside WHP (no `window.WHP_CONFIG`) -- Browse should be hidden. */
export function isStandalone(): boolean {
return typeof window === 'undefined' || !(window as any).WHP_CONFIG;
}
const previewBoxStyle: CSSProperties = {
marginBottom: 8,
borderRadius: 6,
overflow: 'hidden',
border: '1px solid #3f3f46',
position: 'relative',
};
const removeBtnStyle: CSSProperties = {
position: 'absolute',
top: 4,
right: 4,
width: 24,
height: 24,
borderRadius: '50%',
background: 'rgba(0,0,0,0.7)',
border: 'none',
color: '#fff',
cursor: 'pointer',
fontSize: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
const dropZoneStyle = (active: boolean): CSSProperties => ({
padding: '20px 12px',
border: `2px dashed ${active ? '#3b82f6' : '#3f3f46'}`,
borderRadius: 6,
textAlign: 'center',
color: '#71717a',
fontSize: 12,
cursor: 'pointer',
marginBottom: 8,
transition: 'border-color 0.15s',
});
const uploadBtnStyle: CSSProperties = {
flex: 1,
padding: '8px 10px',
fontSize: 12,
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #3f3f46',
background: '#3b82f6',
color: '#fff',
fontWeight: 500,
};
const browseBtnStyle = (active: boolean): CSSProperties => ({
flex: 1,
padding: '8px 10px',
fontSize: 12,
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #3f3f46',
background: active ? '#3b82f6' : '#27272a',
color: active ? '#fff' : '#e4e4e7',
});
const gridStyle: CSSProperties = {
maxHeight: 200,
overflowY: 'auto',
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 4,
marginTop: 8,
background: '#18181b',
borderRadius: 6,
padding: 4,
};
export const AssetPicker: React.FC<AssetPickerProps> = ({
value,
onChange,
mediaType = 'image',
variant = 'full',
placeholder = 'Or paste URL...',
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [showBrowser, setShowBrowser] = useState(false);
const [browserAssets, setBrowserAssets] = useState<Asset[]>([]);
const [browserLoading, setBrowserLoading] = useState(false);
const [urlValue, setUrlValue] = useState(value || '');
const [dragOver, setDragOver] = useState(false);
const standalone = isStandalone();
useEffect(() => {
setUrlValue(value || '');
}, [value]);
const handleUpload = useCallback(async (file: File) => {
const url = await uploadAsset(file);
if (url) {
onChange(url);
setUrlValue(url);
}
}, [onChange]);
const handleBrowseToggle = useCallback(async () => {
if (showBrowser) {
setShowBrowser(false);
return;
}
setBrowserLoading(true);
try {
const assets = await listAssets(mediaType);
setBrowserAssets(assets);
setShowBrowser(true);
} finally {
setBrowserLoading(false);
}
}, [showBrowser, mediaType]);
const handleSelectAsset = useCallback((asset: Asset) => {
onChange(asset.url);
setUrlValue(asset.url);
setShowBrowser(false);
}, [onChange]);
const handleApplyUrl = useCallback(() => {
onChange(urlValue);
}, [urlValue, onChange]);
const handleRemove = useCallback(() => {
onChange('');
setUrlValue('');
}, [onChange]);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file && matchesMediaType(file.type, mediaType)) {
await handleUpload(file);
}
}, [handleUpload, mediaType]);
const accept = acceptFor(mediaType);
const hasValue = !!value;
const fileInput = (
<input
ref={fileInputRef}
type="file"
accept={accept}
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
e.target.value = '';
}}
data-testid="asset-picker-file-input"
/>
);
const grid = showBrowser && (
<div style={gridStyle} data-testid="asset-picker-grid">
{browserAssets.map((asset) => (
<div
key={asset.name}
onClick={() => handleSelectAsset(asset)}
data-testid={`asset-picker-thumb-${asset.name}`}
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
>
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
))}
{browserAssets.length === 0 && (
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>
No assets uploaded yet. Use Upload above.
</p>
)}
</div>
);
const browseButton = !standalone && (
<button
type="button"
onClick={handleBrowseToggle}
data-testid="asset-picker-browse"
style={variant === 'full' ? browseBtnStyle(showBrowser) : { ...browseBtnStyle(showBrowser), flex: 'none', padding: '6px 8px' }}
>
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: variant === 'full' ? 4 : 0 }} />
{variant === 'full' && 'Browse'}
</button>
);
if (variant === 'compact') {
return (
<div data-testid="asset-picker-compact">
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{hasValue && (
<div style={{ width: 28, height: 28, borderRadius: 4, overflow: 'hidden', border: '1px solid #3f3f46', flex: 'none', position: 'relative' }}>
<img src={value} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
<button
type="button"
onClick={handleRemove}
title="Remove"
data-testid="asset-picker-remove"
style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.5)', border: 'none', color: '#fff', fontSize: 9, cursor: 'pointer', opacity: 0 }}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; }}
>
<i className="fa fa-times" />
</button>
</div>
)}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
data-testid="asset-picker-upload"
style={{ flex: 'none', padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff' }}
title="Upload"
>
<i className="fa fa-upload" />
</button>
{browseButton}
<input
type="text"
className="guided-input"
value={urlValue}
placeholder={placeholder}
onChange={(e) => { setUrlValue(e.target.value); }}
onBlur={handleApplyUrl}
onKeyDown={(e) => { if (e.key === 'Enter') handleApplyUrl(); }}
data-testid="asset-picker-url-input"
style={{ flex: 1, fontSize: 11, minWidth: 0 }}
/>
</div>
{grid}
{fileInput}
</div>
);
}
return (
<div data-testid="asset-picker-full">
{hasValue ? (
<div style={previewBoxStyle}>
<img src={value} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} data-testid="asset-picker-preview" />
<button
type="button"
onClick={handleRemove}
title="Remove"
data-testid="asset-picker-remove"
style={removeBtnStyle}
>
<i className="fa fa-times" />
</button>
</div>
) : (
<div
style={dropZoneStyle(dragOver)}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
data-testid="asset-picker-dropzone"
>
<i className="fa fa-cloud-upload" style={{ fontSize: 24, display: 'block', marginBottom: 6, color: '#3b82f6' }} />
Drop {mediaType === 'video' ? 'video' : mediaType === 'any' ? 'file' : 'image'} here or click to upload
</div>
)}
<div style={{ display: 'flex', gap: 4 }}>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
data-testid="asset-picker-upload"
style={uploadBtnStyle}
>
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
</button>
{browseButton}
</div>
{grid}
{fileInput}
<div style={{ marginTop: 6 }}>
<div className="guided-input-row">
<input
type="text"
className="guided-input"
value={urlValue}
placeholder={placeholder}
onChange={(e) => setUrlValue(e.target.value)}
data-testid="asset-picker-url-input"
style={{ fontSize: 11 }}
/>
<button
type="button"
className="preset-btn apply-btn"
onClick={handleApplyUrl}
data-testid="asset-picker-apply"
>
Apply
</button>
</div>
</div>
</div>
);
};