Compare commits

..

4 Commits

Author SHA1 Message Date
jubnl 9f5d2f6488 fix(planner): scroll long place description/notes on mobile (#1195) (#1199)
The place details card (PlaceInspector) clipped long description/notes
with no way to scroll. The content area is a flex column whose children
(description/notes) had the default flex-shrink: 1, so once the card hit
its maxHeight cap they compressed to fit and their overflow:hidden clipped
the text instead of overflowing into a scroll region.

- Make the content area a bounded scroll region (flex: 1 1 auto,
  minHeight: 0, overflowY: auto, momentum + overscroll containment).
- Pin description/notes with flexShrink: 0 so they keep natural height and
  the card overflows into the scroll instead of clipping.
- Pin header/footer with flexShrink: 0 so they stay fixed while scrolling.
- Add wordBreak/overflowWrap to the description div to fix horizontal clip.
2026-06-16 08:39:39 +02:00
jubnl 40253d2fdf fix(places): fall back to search when autocomplete details lookup fails (#1192) (#1198)
Clicking an auto-suggest dropdown item did a second /maps/details lookup
that could fail (details kill-switch off, an overloaded OSM Overpass mirror
behind a proxy, or any upstream error), dead-ending on "Place search failed"
while the search button stayed reliable.

handleSelectSuggestion now treats a missing or coordinate-less details result
(or a thrown error) as a miss and falls back to the text-search path the search
button uses, applying the first result. The error toast only fires if the
fallback also returns nothing. Adds tests for the previously untested
suggestion-click path.
2026-06-16 08:14:01 +02:00
jubnl 910631c1ff fix(backup): restore from Docker, fail-fast on shadowed /app, bundle encryption key (#1193) (#1197)
* fix(backup): restore uploads through symlinked dir and bundle encryption key (#1193)

Restoring a backup inside Docker threw ERR_FS_CP_DIR_TO_NON_DIR because
/app/server/uploads is a symlink to the mounted /app/uploads volume and
cpSync (dereference:false) refuses to overwrite the symlink node with a
directory. The DB was swapped before this failing copy, so users saw
restored data but missing upload files (trip covers). Resolve the symlink
with realpathSync before copying so the merge targets the real directory;
no-op on a plain dir, so non-Docker behavior is unchanged.

Also bundle the at-rest encryption key (data/.encryption_key) into the
backup so a restore onto a different install can decrypt stored secrets
(API keys, MFA, SMTP/OIDC). Skipped when ENCRYPTION_KEY is provided via
env (the file is not the source of truth then). On restore the key is
swapped back if the archive carries one; a restart is required for the
in-memory key to take effect.

* fix(docker): fail fast when a volume shadows /app (#1193)

Mounting an old volume at /app hides the image's node_modules and dist,
so startup crashed with a cryptic "Cannot find module
'tsconfig-paths/register'". Add a CMD preflight that detects the missing
app files and exits with actionable guidance. Document in the README that
only /app/data and /app/uploads should be mounted, never /app.

* fix: ssrf test
2026-06-16 07:43:00 +02:00
jubnl 5b41cab898 chore(ssrf): include lookup error code in error message 2026-06-16 06:52:03 +02:00
10 changed files with 380 additions and 21 deletions
+4 -1
View File
@@ -105,5 +105,8 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
# Preflight: if the app code is missing, a volume was almost certainly mounted
# over /app (it hides the image's node_modules + dist). Fail with actionable
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
+3
View File
@@ -311,6 +311,9 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
> [!IMPORTANT]
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
<h3>Rotating the Encryption Key</h3>
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
@@ -253,6 +253,101 @@ describe('PlaceFormModal', () => {
delete window.__addToast;
});
// ── Autocomplete suggestion click (#1192) ─────────────────────────────────────
// Selecting a dropdown suggestion does a second `details` lookup which is fragile
// (details kill-switch, an overloaded OSM Overpass mirror, upstream errors). When
// it yields no usable place the modal must fall back to the reliable text search
// instead of dead-ending on "Place search failed".
async function openSuggestion(user: ReturnType<typeof userEvent.setup>) {
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Eiffel');
// Debounced autocomplete (300ms) then the dropdown renders the suggestion.
return screen.findByText('Paris, France');
}
it('FE-PLANNER-PLACEFORM-021b: suggestion click falls back to search when details fails', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
source: 'nominatim',
}),
),
// details rejects (e.g. proxy 504 from a hung Overpass mirror)
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
http.post('/api/maps/search', () =>
HttpResponse.json({
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
source: 'openstreetmap',
}),
),
);
render(<PlaceFormModal {...defaultProps} />);
const suggestion = await openSuggestion(user);
await user.click(suggestion);
// Form is populated from the search fallback, and no error toast is shown.
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
expect(screen.getByDisplayValue('2.2945')).toBeInTheDocument();
expect(addToast).not.toHaveBeenCalledWith(expect.anything(), 'error', expect.anything());
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
source: 'nominatim',
}),
),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
http.post('/api/maps/search', () =>
HttpResponse.json({
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
source: 'openstreetmap',
}),
),
);
render(<PlaceFormModal {...defaultProps} />);
const suggestion = await openSuggestion(user);
await user.click(suggestion);
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-021d: suggestion click shows error only when the fallback also finds nothing', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
source: 'nominatim',
}),
),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
http.post('/api/maps/search', () => HttpResponse.json({ places: [], source: 'openstreetmap' })),
);
render(<PlaceFormModal {...defaultProps} />);
const suggestion = await openSuggestion(user);
await user.click(suggestion);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith('Place search failed.', 'error', undefined);
});
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
// hasMapsKey is false by default in beforeEach
render(<PlaceFormModal {...defaultProps} />);
@@ -249,15 +249,34 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
setForm(prev => ({ ...prev, name: suggestion.mainText }))
setIsSearchingMaps(true)
try {
const result = await mapsApi.details(suggestion.placeId, language)
if (result.place) {
handleSelectMapsResult(result.place)
// The details lookup is a fragile second hop — it can fail when the
// details kill-switch is off, when the OSM Overpass mirror is overloaded,
// or on any upstream error. Treat a missing/coordinate-less place as a
// miss and fall back to the reliable text-search path the search button
// uses (its results already carry coordinates), so dropdown items stay
// clickable instead of dead-ending on "Place search failed". (#1192)
let place: Record<string, unknown> | null = null
try {
const result = await mapsApi.details(suggestion.placeId, language)
if (result.place && result.place.lat != null && result.place.lng != null) {
place = result.place
}
} catch (err) {
console.error('Failed to fetch place details:', err)
}
if (!place) {
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
const search = await mapsApi.search(query, language)
place = search.places?.[0] ?? null
}
if (place) {
handleSelectMapsResult(place)
} else {
setMapsSearch(previousSearch)
toast.error(t('places.mapsSearchError'))
}
} catch (err) {
console.error('Failed to fetch place details:', err)
console.error('Place suggestion lookup failed:', err)
setMapsSearch(previousSearch)
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
@@ -647,5 +647,43 @@ describe('PlaceInspector', () => {
expect(screen.queryByText('Participants')).toBeNull();
});
// ── Scroll / overflow (issue #1195) ──────────────────────────────────────
it('FE-PLANNER-INSPECTOR-046: content area is a bounded flex scroll region', () => {
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
const p = buildPlace({ id: 200, description: longText, notes: longText } as any);
render(<PlaceInspector {...defaultProps} place={p} />);
const scroll = screen.getByTestId('inspector-scroll') as HTMLElement;
expect(scroll.style.overflowY).toBe('auto');
expect(scroll.style.minHeight).toBe('0px');
// flex must allow the region to shrink/grow within the capped card
expect(scroll.style.flex).not.toBe('');
expect(scroll.style.flex).not.toBe('0 0 auto');
});
it('FE-PLANNER-INSPECTOR-047: long unbroken description wraps instead of clipping horizontally', () => {
const longWord = 'https://example.com/' + 'a'.repeat(300);
const p = buildPlace({ id: 201, description: longWord } as any);
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
const descDiv = container.querySelector('.collab-note-md') as HTMLElement;
expect(descDiv).toBeTruthy();
expect(descDiv.style.overflowWrap).toBe('anywhere');
expect(descDiv.style.wordBreak).toBe('break-word');
});
it('FE-PLANNER-INSPECTOR-048: description/notes do not shrink so the card scrolls instead of clipping', () => {
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
const p = buildPlace({ id: 202, description: longText, notes: longText } as any);
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
const notes = Array.from(container.querySelectorAll('.collab-note-md')) as HTMLElement[];
// Both description and notes containers must keep their natural height
// (flex-shrink: 0) — otherwise they compress inside the flex column and
// overflow:hidden clips the text with no scroll (issue #1195).
expect(notes.length).toBe(2);
for (const el of notes) {
expect(el.style.flexShrink).toBe('0');
}
});
});
@@ -217,7 +217,7 @@ export default function PlaceInspector({
locale={locale} timeFormat={timeFormat} onClose={onClose} />
{/* Content — scrollable */}
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<div data-testid="inspector-scroll" style={{ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', WebkitOverflowScrolling: 'touch', overscrollBehavior: 'contain', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Info-Chips — hidden on mobile, shown on desktop */}
<div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
@@ -253,14 +253,14 @@ export default function PlaceInspector({
{/* Description / Summary */}
{(place.description || googleDetails?.summary) && (
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px' }}>
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
</div>
)}
{/* Notes */}
{place.notes && (
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
</div>
)}
@@ -279,7 +279,7 @@ export default function PlaceInspector({
</div>
{/* Footer actions */}
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', flexShrink: 0 }}>
{selectedDayId && (
assignmentInDay ? (
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
@@ -497,7 +497,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameInputRef, nameValue, setNameValue,
commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, locale, timeFormat, onClose }: any) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
{/* Avatar with open/closed ring + tag */}
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
<div style={{
+27 -1
View File
@@ -155,6 +155,17 @@ export async function createBackup(): Promise<BackupInfo> {
archive.file(dbPath, { name: 'travel.db' });
}
// Bundle the at-rest encryption key so the backup is self-contained: the
// DB stores secrets (API keys, MFA, SMTP/OIDC) encrypted with this key, so
// a restore onto a different install would otherwise be unable to decrypt
// them. NOTE: this makes the backup file as sensitive as the key itself —
// store/transfer it securely. Skipped when ENCRYPTION_KEY is provided via
// env, since in that case the file is not the source of truth.
const encKeyPath = path.join(dataDir, '.encryption_key');
if (!process.env.ENCRYPTION_KEY && fs.existsSync(encKeyPath)) {
archive.file(encKeyPath, { name: '.encryption_key' });
}
if (fs.existsSync(uploadsDir)) {
// Exclude the place-photo and trek-memory caches: both are re-derivable
// (re-fetched on demand, keyed on stable ids) and would otherwise dominate
@@ -252,6 +263,16 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
}
fs.copyFileSync(extractedDb, dbDest);
// Restore the bundled at-rest encryption key (if the archive carries one)
// so the restored DB's encrypted secrets can be decrypted. Only the file
// is swapped here; the in-memory key was read at startup, so a restart is
// required for it to take effect (and an explicit ENCRYPTION_KEY env var
// still overrides the file).
const extractedEncKey = path.join(extractDir, '.encryption_key');
if (fs.existsSync(extractedEncKey)) {
fs.copyFileSync(extractedEncKey, path.join(dataDir, '.encryption_key'));
}
const extractedUploads = path.join(extractDir, 'uploads');
if (fs.existsSync(extractedUploads)) {
for (const sub of fs.readdirSync(uploadsDir)) {
@@ -262,7 +283,12 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
}
}
}
fs.cpSync(extractedUploads, uploadsDir, { recursive: true, force: true });
// Copy into the real directory behind uploadsDir. In Docker, uploadsDir
// (/app/server/uploads) is a symlink to the mounted /app/uploads volume;
// cpSync(dereference:false) would otherwise try to overwrite the symlink
// node with a directory and throw ERR_FS_CP_DIR_TO_NON_DIR. realpathSync
// is a no-op when uploadsDir is a plain directory (dev/non-Docker).
fs.cpSync(extractedUploads, fs.realpathSync(uploadsDir), { recursive: true, force: true });
}
} finally {
// Reopening the DB must always run (even if the copy above threw) so the
+11 -9
View File
@@ -16,11 +16,11 @@ function isAlwaysBlocked(ip: string): boolean {
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
// Loopback
if (addr.startsWith("127.") || addr === '::1') return true;
if (addr.startsWith('127.') || addr === '::1') return true;
// Unspecified
if (addr.startsWith("0.")) return true;
if (addr.startsWith('0.')) return true;
// Link-local / cloud metadata
if (addr.startsWith("169.254.") || /^fe80:/i.test(addr)) return true;
if (addr.startsWith('169.254.') || /^fe80:/i.test(addr)) return true;
// IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x
if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true;
@@ -32,9 +32,9 @@ function isPrivateNetwork(ip: string): boolean {
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
// RFC-1918 private ranges
if (addr.startsWith("10.")) return true;
if (addr.startsWith('10.')) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
if (addr.startsWith("192.168.")) return true;
if (addr.startsWith('192.168.')) return true;
// CGNAT / Tailscale shared address space (100.64.0.0/10)
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true;
// IPv6 ULA (fc00::/7)
@@ -71,8 +71,9 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
try {
const result = await dns.lookup(hostname);
resolvedIp = result.address;
} catch {
return { allowed: false, isPrivate: false, error: 'Could not resolve hostname' };
} catch (error_) {
const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown';
return { allowed: false, isPrivate: false, error: `Could not resolve hostname (${code})` };
}
if (isAlwaysBlocked(resolvedIp)) {
@@ -90,7 +91,8 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
allowed: false,
isPrivate: true,
resolvedIp,
error: 'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.',
error:
'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.',
};
}
return { allowed: true, isPrivate: true, resolvedIp };
@@ -187,7 +189,7 @@ export async function safeFetchFollow(
// (2xx/4xx/5xx, or a 3xx with no Location) is the final response.
const status = typeof response.status === 'number' ? response.status : 0;
const isRedirectStatus = status >= 300 && status < 400;
const location = isRedirectStatus ? response.headers?.get('location') ?? null : null;
const location = isRedirectStatus ? (response.headers?.get('location') ?? null) : null;
if (!location) {
return response;
}
@@ -19,6 +19,9 @@ const fsMock = vi.hoisted(() => ({
rmSync: vi.fn(),
copyFileSync: vi.fn(),
cpSync: vi.fn(),
// Identity by default: when uploadsDir is a plain directory, realpathSync
// returns it unchanged. Tests that exercise the symlink case override this.
realpathSync: vi.fn((p: string) => p),
}));
const archiverInstanceMock = vi.hoisted(() => ({
@@ -479,6 +482,71 @@ describe('BACKUP-036 createBackup', () => {
// The re-derivable caches must not be archived verbatim.
expect(archiverInstanceMock.directory).not.toHaveBeenCalled();
});
it('BACKUP-036f — bundles .encryption_key when present and ENCRYPTION_KEY env is unset', async () => {
const prevEnvKey = process.env.ENCRYPTION_KEY;
delete process.env.ENCRYPTION_KEY;
try {
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('.encryption_key'));
fsMock.mkdirSync.mockReturnValue(undefined);
const writableEvents: Record<string, Function> = {};
const fakeWriteStream = {
on: vi.fn((event: string, cb: Function) => {
writableEvents[event] = cb;
}),
};
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
archiverInstanceMock.on.mockImplementation((_e: string, _cb: Function) => {});
archiverInstanceMock.pipe.mockReturnValue(undefined);
archiverInstanceMock.finalize.mockImplementation(() => {
if (writableEvents['close']) writableEvents['close']();
});
archiverMock.mockReturnValue(archiverInstanceMock);
fsMock.statSync.mockReturnValue({ size: 1024, birthtime: new Date('2026-04-06T12:00:00Z') });
await createBackup();
expect(archiverInstanceMock.file).toHaveBeenCalledWith(
expect.stringContaining('.encryption_key'),
{ name: '.encryption_key' },
);
} finally {
process.env.ENCRYPTION_KEY = prevEnvKey;
}
});
it('BACKUP-036g — does NOT bundle .encryption_key when ENCRYPTION_KEY env is set', async () => {
// setup.ts sets process.env.ENCRYPTION_KEY, so the env is the source of truth.
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('.encryption_key'));
fsMock.mkdirSync.mockReturnValue(undefined);
const writableEvents: Record<string, Function> = {};
const fakeWriteStream = {
on: vi.fn((event: string, cb: Function) => {
writableEvents[event] = cb;
}),
};
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
archiverInstanceMock.on.mockImplementation((_e: string, _cb: Function) => {});
archiverInstanceMock.pipe.mockReturnValue(undefined);
archiverInstanceMock.finalize.mockImplementation(() => {
if (writableEvents['close']) writableEvents['close']();
});
archiverMock.mockReturnValue(archiverInstanceMock);
fsMock.statSync.mockReturnValue({ size: 1024, birthtime: new Date('2026-04-06T12:00:00Z') });
await createBackup();
expect(archiverInstanceMock.file).not.toHaveBeenCalledWith(
expect.stringContaining('.encryption_key'),
expect.anything(),
);
});
});
// ---------------------------------------------------------------------------
@@ -856,6 +924,53 @@ describe('BACKUP-045 restoreFromZip — full success path (no uploads)', () => {
expect(dbMock.reinitialize).toHaveBeenCalled();
});
it('BACKUP-045d — restores bundled .encryption_key when the archive carries one', async () => {
setupSuccessfulExtraction();
setupAllTablesPresent();
fsMock.existsSync.mockImplementation((p: string) => {
if (String(p).endsWith('travel.db')) return true;
if (String(p).endsWith('.encryption_key')) return true; // extracted key present
if (String(p).includes('uploads')) return false;
return true;
});
fsMock.unlinkSync.mockReturnValue(undefined);
fsMock.copyFileSync.mockReturnValue(undefined);
fsMock.rmSync.mockReturnValue(undefined);
const result = await restoreFromZip('/data/tmp/upload.zip');
expect(result).toEqual({ success: true });
// Key copied from the extract dir into the live data dir.
expect(fsMock.copyFileSync).toHaveBeenCalledWith(
expect.stringContaining('.encryption_key'),
expect.stringContaining('.encryption_key'),
);
});
it('BACKUP-045e — skips key restore when the archive has no .encryption_key', async () => {
setupSuccessfulExtraction();
setupAllTablesPresent();
fsMock.existsSync.mockImplementation((p: string) => {
if (String(p).endsWith('travel.db')) return true;
if (String(p).endsWith('.encryption_key')) return false; // no key in archive
if (String(p).includes('uploads')) return false;
return true;
});
fsMock.unlinkSync.mockReturnValue(undefined);
fsMock.copyFileSync.mockReturnValue(undefined);
fsMock.rmSync.mockReturnValue(undefined);
const result = await restoreFromZip('/data/tmp/upload.zip');
expect(result).toEqual({ success: true });
expect(fsMock.copyFileSync).not.toHaveBeenCalledWith(
expect.stringContaining('.encryption_key'),
expect.stringContaining('.encryption_key'),
);
});
});
describe('BACKUP-046 restoreFromZip — with uploads directory', () => {
@@ -912,6 +1027,64 @@ describe('BACKUP-046 restoreFromZip — with uploads directory', () => {
{ recursive: true, force: true }
);
});
it('BACKUP-046b — copies into the symlink target, not the symlink itself (#1193)', async () => {
// In Docker, uploadsDir (/app/server/uploads) is a symlink to the mounted
// /app/uploads volume. cpSync(dereference:false) would throw
// ERR_FS_CP_DIR_TO_NON_DIR overwriting the symlink node with a directory.
// The fix resolves the symlink with realpathSync first, so the copy targets
// the real directory behind it.
setupSuccessfulExtraction();
const fakeDbInstance = {
prepare: vi.fn()
.mockReturnValueOnce({
get: vi.fn().mockReturnValue({ integrity_check: 'ok' }),
})
.mockReturnValueOnce({
all: vi.fn().mockReturnValue([
{ name: 'users' },
{ name: 'trips' },
{ name: 'trip_members' },
{ name: 'places' },
{ name: 'days' },
]),
}),
close: vi.fn(),
};
DatabaseMock.mockReturnValue(fakeDbInstance);
fsMock.existsSync.mockImplementation((p: string) => {
if (String(p).endsWith('travel.db')) return true;
if (String(p).includes('uploads')) return true;
return true;
});
fsMock.readdirSync.mockImplementation((p: string) => {
if (String(p).includes('uploads') && !String(p).includes('restore-')) {
return ['photos'] as any;
}
if (String(p).includes('photos')) return ['img1.jpg'] as any;
return [] as any;
});
fsMock.statSync.mockReturnValue({ isDirectory: () => true } as any);
fsMock.unlinkSync.mockReturnValue(undefined);
fsMock.copyFileSync.mockReturnValue(undefined);
fsMock.cpSync.mockReturnValue(undefined);
fsMock.rmSync.mockReturnValue(undefined);
// Resolve the uploads symlink to a distinct real target directory.
const REAL_TARGET = '/app/uploads';
fsMock.realpathSync.mockReturnValueOnce(REAL_TARGET);
const result = await restoreFromZip('/data/tmp/upload.zip');
expect(result).toEqual({ success: true });
// The copy destination must be the resolved real path, never the symlink.
expect(fsMock.cpSync).toHaveBeenCalledWith(
expect.stringContaining('uploads'),
REAL_TARGET,
{ recursive: true, force: true }
);
});
});
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -163,7 +163,7 @@ describe('checkSsrf', () => {
const result = await checkSsrf('http://nxdomain.example.com');
expect(result.allowed).toBe(false);
expect(result.isPrivate).toBe(false);
expect(result.error).toBe('Could not resolve hostname');
expect(result.error).toContain('Could not resolve hostname');
});
});