setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
+ onChange={v => setHotelDayRange(prev => ({ start: v, end: days.findIndex(d => d.id === v) > days.findIndex(d => d.id === prev.end) ? v : prev.end }))}
options={days.map((d, i) => ({
value: d.id,
label: d.title || t('planner.dayN', { n: i + 1 }),
@@ -478,7 +478,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
+ onChange={v => setHotelDayRange(prev => ({ start: days.findIndex(d => d.id === v) < days.findIndex(d => d.id === prev.start) ? v : prev.start, end: v }))}
options={days.map((d, i) => ({
value: d.id,
label: d.title || t('planner.dayN', { n: i + 1 }),
diff --git a/client/src/pages/LoginPage.oidc-redirect.test.tsx b/client/src/pages/LoginPage.oidc-redirect.test.tsx
new file mode 100644
index 00000000..c14e0d96
--- /dev/null
+++ b/client/src/pages/LoginPage.oidc-redirect.test.tsx
@@ -0,0 +1,105 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { render, screen, waitFor } from '../../tests/helpers/render';
+import { http, HttpResponse } from 'msw';
+import { server } from '../../tests/helpers/msw/server';
+import { resetAllStores } from '../../tests/helpers/store';
+import LoginPage from './LoginPage';
+
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return { ...actual, useNavigate: () => mockNavigate };
+});
+
+describe('LoginPage — OIDC redirect preservation', () => {
+ let savedLocation: Location;
+
+ beforeEach(() => {
+ resetAllStores();
+ mockNavigate.mockClear();
+ sessionStorage.clear();
+ savedLocation = window.location;
+ });
+
+ afterEach(() => {
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ writable: true,
+ value: savedLocation,
+ });
+ });
+
+ function setSearch(search: string) {
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ writable: true,
+ value: { ...window.location, search },
+ });
+ }
+
+ describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => {
+ it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => {
+ setSearch('?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dfoo');
+ render();
+
+ await waitFor(() => {
+ expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/authorize?client_id=foo');
+ });
+ });
+
+ it('does not write to sessionStorage when no redirect param is present', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('your@email.com')).toBeInTheDocument();
+ });
+
+ expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
+ });
+ });
+
+ describe('FE-PAGE-LOGIN-023: OIDC code exchange navigates to sessionStorage redirect', () => {
+ beforeEach(() => {
+ server.use(
+ http.get('/api/auth/oidc/exchange', () =>
+ HttpResponse.json({ token: 'mock-oidc-token' })
+ ),
+ );
+ });
+
+ it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => {
+ sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo&state=xyz');
+ setSearch('?oidc_code=testcode123');
+ render();
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith(
+ '/oauth/authorize?client_id=foo&state=xyz',
+ { replace: true },
+ );
+ });
+
+ expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
+ });
+
+ it('falls back to /dashboard when no sessionStorage redirect is set', async () => {
+ setSearch('?oidc_code=testcode123');
+ render();
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true });
+ });
+ });
+ });
+
+ describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => {
+ it('removes oidc_redirect from sessionStorage on OIDC error', async () => {
+ sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo');
+ setSearch('?oidc_error=token_failed');
+ render();
+
+ await waitFor(() => {
+ expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
+ });
+ });
+ });
+});
diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx
index d4646635..6fa2c192 100644
--- a/client/src/pages/LoginPage.tsx
+++ b/client/src/pages/LoginPage.tsx
@@ -55,6 +55,12 @@ export default function LoginPage(): React.ReactElement {
return '/dashboard'
}, [])
+ useEffect(() => {
+ if (redirectTarget !== '/dashboard') {
+ sessionStorage.setItem('oidc_redirect', redirectTarget)
+ }
+ }, [redirectTarget])
+
useEffect(() => {
const params = new URLSearchParams(window.location.search)
@@ -83,7 +89,9 @@ export default function LoginPage(): React.ReactElement {
window.history.replaceState({}, '', '/login')
if (data.token) {
await loadUser()
- navigate('/dashboard', { replace: true })
+ const savedRedirect = sessionStorage.getItem('oidc_redirect') || '/dashboard'
+ sessionStorage.removeItem('oidc_redirect')
+ navigate(savedRedirect, { replace: true })
} else {
setError(data.error || t('login.oidcFailed'))
}
@@ -104,6 +112,7 @@ export default function LoginPage(): React.ReactElement {
invalid_state: t('login.oidc.invalidState'),
}
setError(errorMessages[oidcError] || oidcError)
+ sessionStorage.removeItem('oidc_redirect')
window.history.replaceState({}, '', '/login')
return
}
diff --git a/client/src/pages/OAuthAuthorizePage.tsx b/client/src/pages/OAuthAuthorizePage.tsx
index 457d96ae..681326f2 100644
--- a/client/src/pages/OAuthAuthorizePage.tsx
+++ b/client/src/pages/OAuthAuthorizePage.tsx
@@ -124,7 +124,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
}
function handleLoginRedirect() {
- const next = '/oauth/authorize?' + params.toString()
+ const next = '/oauth/authorize?' + params.toString() + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(next)
}
diff --git a/server/src/app.ts b/server/src/app.ts
index d21c0d3c..cc83b645 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -53,7 +53,7 @@ export function createApp(): express.Application {
const app = express();
// Trust first proxy (nginx/Docker) for correct req.ip
- if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
+ if (process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.TRUST_PROXY) {
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
}
@@ -67,13 +67,13 @@ export function createApp(): express.Application {
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
else callback(new Error('Not allowed by CORS'));
};
- } else if (process.env.NODE_ENV === 'production') {
+ } else if (process.env.NODE_ENV?.toLowerCase() === 'production') {
corsOrigin = false;
} else {
corsOrigin = true;
}
- const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
+ const shouldForceHttps = process.env.FORCE_HTTPS?.toLowerCase() === 'true';
// HSTS is worth enabling any time we're serving production traffic,
// not only when FORCE_HTTPS is set. Self-hosters behind Traefik /
// Caddy / Cloudflare Tunnel typically leave FORCE_HTTPS unset (the
diff --git a/server/src/config.ts b/server/src/config.ts
index 2941a87f..c9255cc9 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -105,7 +105,7 @@ export const ENCRYPTION_KEY = _encryptionKey;
// Must stay in sync with client/src/i18n/supportedLanguages.ts (canonical source).
// Kept duplicated here because server and client are separate npm packages.
const SUPPORTED_LANG_CODES = ['de', 'en', 'es', 'fr', 'hu', 'nl', 'br', 'cs', 'pl', 'ru', 'zh', 'zh-TW', 'it', 'ar'];
-const rawDefaultLang = process.env.DEFAULT_LANGUAGE || 'en';
+const rawDefaultLang = process.env.DEFAULT_LANGUAGE?.toLowerCase() || 'en';
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
}
diff --git a/server/src/db/database.ts b/server/src/db/database.ts
index 2578608b..e7057fd4 100644
--- a/server/src/db/database.ts
+++ b/server/src/db/database.ts
@@ -47,7 +47,7 @@ const db = new Proxy({} as Database.Database, {
},
});
-if (process.env.DEMO_MODE === 'true') {
+if (process.env.DEMO_MODE?.toLowerCase() === 'true') {
try {
const { seedDemoData } = require('../demo/demo-seed');
seedDemoData(_db);
diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts
index d6e01d54..299e3f5a 100644
--- a/server/src/db/seeds.ts
+++ b/server/src/db/seeds.ts
@@ -6,7 +6,7 @@ import crypto from 'crypto';
// are only relevant after the first user exists; at that point seeds have already
// finished and skip via the userCount > 0 guard above.
function isOidcOnlyConfigured(): boolean {
- if (process.env.OIDC_ONLY !== 'true') return false;
+ if (process.env.OIDC_ONLY?.toLowerCase() !== 'true') return false;
return !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID);
}
diff --git a/server/src/index.ts b/server/src/index.ts
index 3f1bf8dd..0e699f37 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -29,8 +29,9 @@ const server = app.listen(PORT, () => {
const banner = [
'──────────────────────────────────────',
' TREK API started',
+ ` Version ${process.env.APP_VERSION}`,
` Port: ${PORT}`,
- ` Environment: ${process.env.NODE_ENV || 'development'}`,
+ ` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`,
` Timezone: ${tz}`,
` Origins: ${origins}`,
` Log level: ${LOG_LVL}`,
@@ -40,8 +41,8 @@ const server = app.listen(PORT, () => {
'──────────────────────────────────────',
];
banner.forEach(l => console.log(l));
- if (process.env.DEMO_MODE === 'true') sLogInfo('Demo mode: ENABLED');
- if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') {
+ if (process.env.DEMO_MODE?.toLowerCase() === 'true') sLogInfo('Demo mode: ENABLED');
+ if (process.env.DEMO_MODE?.toLowerCase() === 'true' && process.env.NODE_ENV?.toLowerCase() === 'production') {
sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');
}
scheduler.start();
diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts
index 9f254403..18be7236 100644
--- a/server/src/middleware/auth.ts
+++ b/server/src/middleware/auth.ts
@@ -105,7 +105,7 @@ const adminOnly = (req: Request, res: Response, next: NextFunction): void => {
const demoUploadBlock = (req: Request, res: Response, next: NextFunction): void => {
const authReq = req as AuthRequest;
- if (process.env.DEMO_MODE === 'true' && isDemoEmail(authReq.user?.email)) {
+ if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(authReq.user?.email)) {
res.status(403).json({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' });
return;
}
diff --git a/server/src/middleware/mfaPolicy.ts b/server/src/middleware/mfaPolicy.ts
index b253acc0..70b00896 100644
--- a/server/src/middleware/mfaPolicy.ts
+++ b/server/src/middleware/mfaPolicy.ts
@@ -68,7 +68,7 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu
return;
}
- if (process.env.DEMO_MODE === 'true' && verified.email && DEMO_EMAILS.has(verified.email)) {
+ if (process.env.DEMO_MODE?.toLowerCase() === 'true' && verified.email && DEMO_EMAILS.has(verified.email)) {
next();
return;
}
diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts
index 7cd32712..16a5b40b 100644
--- a/server/src/routes/admin.ts
+++ b/server/src/routes/admin.ts
@@ -449,7 +449,7 @@ router.put('/default-user-settings', (req: Request, res: Response) => {
});
// ── Dev-only: test notification endpoints ──────────────────────────────────────
-if (process.env.NODE_ENV === 'development') {
+if (process.env.NODE_ENV?.toLowerCase() === 'development') {
const { send } = require('../services/notificationService');
router.post('/dev/test-notification', async (req: Request, res: Response) => {
diff --git a/server/src/routes/backup.ts b/server/src/routes/backup.ts
index 2d9ce088..58e5995a 100644
--- a/server/src/routes/backup.ts
+++ b/server/src/routes/backup.ts
@@ -168,7 +168,7 @@ router.put('/auto-settings', (req: Request, res: Response) => {
const msg = err instanceof Error ? err.message : String(err);
res.status(500).json({
error: 'Could not save auto-backup settings',
- detail: process.env.NODE_ENV !== 'production' ? msg : undefined,
+ detail: process.env.NODE_ENV?.toLowerCase() !== 'production' ? msg : undefined,
});
}
});
diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts
index 4a86a340..7aefe593 100644
--- a/server/src/routes/oidc.ts
+++ b/server/src/routes/oidc.ts
@@ -30,7 +30,7 @@ router.get('/login', async (req: Request, res: Response) => {
const config = getOidcConfig();
if (!config) return res.status(400).json({ error: 'OIDC not configured' });
- if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV === 'production') {
+ if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV?.toLowerCase() === 'production') {
return res.status(400).json({ error: 'OIDC issuer must use HTTPS in production' });
}
@@ -85,7 +85,7 @@ router.get('/callback', async (req: Request, res: Response) => {
const config = getOidcConfig();
if (!config) return res.redirect(frontendUrl('/login?oidc_error=not_configured'));
- if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV === 'production') {
+ if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV?.toLowerCase() === 'production') {
return res.redirect(frontendUrl('/login?oidc_error=issuer_not_https'));
}
diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts
index 87979ecc..13177b7f 100644
--- a/server/src/scheduler.ts
+++ b/server/src/scheduler.ts
@@ -139,7 +139,7 @@ let demoTask: ScheduledTask | null = null;
function startDemoReset(): void {
if (demoTask) { demoTask.stop(); demoTask = null; }
- if (process.env.DEMO_MODE !== 'true') return;
+ if (process.env.DEMO_MODE?.toLowerCase() !== 'true') return;
demoTask = cron.schedule('0 * * * *', () => {
try {
diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts
index 66a2d349..f0fc5420 100644
--- a/server/src/services/adminService.ts
+++ b/server/src/services/adminService.ts
@@ -8,6 +8,7 @@ import { updateJwtSecret } from '../config';
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
+import { deleteUserCompletely } from './userCleanupService';
import { validatePassword } from './passwordPolicy';
import { getPhotoProviderConfig } from './memories/helpersService';
import { send as sendNotification } from './notificationService';
@@ -170,7 +171,7 @@ export function deleteUser(id: string, currentUserId: number) {
const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as { id: number; email: string } | undefined;
if (!userToDel) return { error: 'User not found', status: 404 };
- db.prepare('DELETE FROM users WHERE id = ?').run(id);
+ deleteUserCompletely(userToDel.id);
return { email: userToDel.email };
}
@@ -287,7 +288,7 @@ export function updateOidcSettings(data: {
// ── Demo Baseline ──────────────────────────────────────────────────────────
export function saveDemoBaseline(): { error?: string; status?: number; message?: string } {
- if (process.env.DEMO_MODE !== 'true') {
+ if (process.env.DEMO_MODE?.toLowerCase() !== 'true') {
return { error: 'Not found', status: 404 };
}
try {
diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts
index 3917e64b..ba949481 100644
--- a/server/src/services/authService.ts
+++ b/server/src/services/authService.ts
@@ -15,6 +15,7 @@ import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from './apiKe
import { createEphemeralToken } from './ephemeralTokens';
import { revokeUserSessions } from '../mcp';
import { startTripReminders } from '../scheduler';
+import { deleteUserCompletely } from './userCleanupService';
import { verifyJwtAndLoadUser } from '../middleware/auth';
import { User } from '../types';
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
@@ -130,7 +131,7 @@ export function resolveAuthToggles(): {
oidc_login: get('oidc_login') !== 'false',
oidc_registration: get('oidc_registration') !== 'false',
};
- if (process.env.OIDC_ONLY === 'true') {
+ if (process.env.OIDC_ONLY?.toLowerCase() === 'true') {
result.password_login = false;
result.password_registration = false;
}
@@ -138,7 +139,7 @@ export function resolveAuthToggles(): {
}
// Legacy fallback
- const oidcOnlyEnabled = process.env.OIDC_ONLY === 'true' || get('oidc_only') === 'true';
+ const oidcOnlyEnabled = process.env.OIDC_ONLY?.toLowerCase() === 'true' || get('oidc_only') === 'true';
const oidcConfigured = !!(
(process.env.OIDC_ISSUER || get('oidc_issuer')) &&
(process.env.OIDC_CLIENT_ID || get('oidc_client_id'))
@@ -252,7 +253,7 @@ export function getPendingMfaSecret(userId: number): string | null {
export function getAppConfig(authenticatedUser: { id: number } | null) {
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
- const isDemo = process.env.DEMO_MODE === 'true';
+ const isDemo = process.env.DEMO_MODE?.toLowerCase() === 'true';
const toggles = resolveAuthToggles();
const version: string = process.env.APP_VERSION ?? require('../../package.json').version;
const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get();
@@ -527,7 +528,7 @@ export function deleteAccount(userId: number, userEmail: string, userRole: strin
return { error: 'Cannot delete the last admin account', status: 400 };
}
}
- db.prepare('DELETE FROM users WHERE id = ?').run(userId);
+ deleteUserCompletely(userId);
return { success: true };
}
diff --git a/server/src/services/cookie.ts b/server/src/services/cookie.ts
index d1e88d2a..c97e187c 100644
--- a/server/src/services/cookie.ts
+++ b/server/src/services/cookie.ts
@@ -18,10 +18,10 @@ const COOKIE_NAME = 'trek_session';
* remains the explicit escape hatch for plain-HTTP LAN testing.
*/
export function cookieOptions(clear = false, req?: Request) {
- if (process.env.COOKIE_SECURE === 'false') {
+ if (process.env.COOKIE_SECURE?.toLowerCase() === 'false') {
return buildOptions(clear, false);
}
- const envSecure = process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true';
+ const envSecure = process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.FORCE_HTTPS?.toLowerCase() === 'true';
const requestSecure = req?.secure === true;
return buildOptions(clear, envSecure || requestSecure);
}
diff --git a/server/src/services/notificationService.ts b/server/src/services/notificationService.ts
index f14e48d8..67f02f2a 100644
--- a/server/src/services/notificationService.ts
+++ b/server/src/services/notificationService.ts
@@ -170,7 +170,7 @@ export async function send(payload: NotificationPayload): Promise {
const configEntry = EVENT_NOTIFICATION_CONFIG[event];
if (!configEntry) {
logDebug(`notificationService.send: unknown event type "${event}", using fallback`);
- if (process.env.NODE_ENV === 'development' && actorId != null) {
+ if (process.env.NODE_ENV?.toLowerCase() === 'development' && actorId != null) {
const devSender = (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(actorId) as { username: string; avatar: string | null } | undefined) ?? null;
createNotificationForRecipient({
type: 'simple',
diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts
index 1f129d2e..5b37f49f 100644
--- a/server/src/services/tripService.ts
+++ b/server/src/services/tripService.ts
@@ -117,10 +117,11 @@ export function generateDays(tripId: number | bigint | string, startDate: string
}
}
- // Overflow dated days (trip shrunk): convert to dateless instead of deleting
- const nullify = db.prepare('UPDATE days SET date = NULL, day_number = ? WHERE id = ?');
+ // Overflow dated days (trip shrunk): delete them (issue #909).
+ // Cascade removes their assignments, notes, and accommodations.
+ const del = db.prepare('DELETE FROM days WHERE id = ?');
for (let i = targetDates.length; i < dated.length; i++) {
- nullify.run(targetDates.length + (i - targetDates.length) + 1, dated[i].id);
+ del.run(dated[i].id);
}
// Any remaining unused dateless days: keep as dateless, just renumber.
diff --git a/server/src/services/userCleanupService.ts b/server/src/services/userCleanupService.ts
new file mode 100644
index 00000000..84239449
--- /dev/null
+++ b/server/src/services/userCleanupService.ts
@@ -0,0 +1,21 @@
+import { db } from '../db/database';
+
+function cleanupUserReferences(userId: number): void {
+ db.prepare('UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?').run(userId);
+ db.prepare('UPDATE budget_items SET paid_by_user_id = NULL WHERE paid_by_user_id = ?').run(userId);
+ db.prepare('DELETE FROM share_tokens WHERE created_by = ?').run(userId);
+ db.prepare('DELETE FROM journey_share_tokens WHERE created_by = ?').run(userId);
+ // Owned journeys cascade-delete their entries/contributors/share_tokens/photos via journey_id FKs
+ db.prepare('DELETE FROM journeys WHERE user_id = ?').run(userId);
+ // Entries authored on other users' journeys (not covered by the cascade above)
+ db.prepare('DELETE FROM journey_entries WHERE author_id = ?').run(userId);
+ db.prepare('DELETE FROM journey_contributors WHERE user_id = ?').run(userId);
+}
+
+export function deleteUserCompletely(userId: number): void {
+ const tx = db.transaction((id: number) => {
+ cleanupUserReferences(id);
+ db.prepare('DELETE FROM users WHERE id = ?').run(id);
+ });
+ tx(userId);
+}
diff --git a/server/src/utils/ssrfGuard.ts b/server/src/utils/ssrfGuard.ts
index d5f2aa3f..19ed98dc 100644
--- a/server/src/utils/ssrfGuard.ts
+++ b/server/src/utils/ssrfGuard.ts
@@ -1,7 +1,7 @@
import dns from 'node:dns/promises';
import { Agent } from 'undici';
-const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK === 'true';
+const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK?.toLowerCase() === 'true';
export interface SsrfResult {
allowed: boolean;
diff --git a/server/tests/integration/admin.test.ts b/server/tests/integration/admin.test.ts
index 0d105c61..beeaa0a1 100644
--- a/server/tests/integration/admin.test.ts
+++ b/server/tests/integration/admin.test.ts
@@ -41,7 +41,7 @@ import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
-import { createUser, createAdmin, createInviteToken } from '../helpers/factories';
+import { createUser, createAdmin, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
@@ -148,6 +148,216 @@ describe('Admin user management', () => {
expect(deleted).toBeUndefined();
});
+ it('ADMIN-005b — DELETE /admin/users/:id succeeds when user has FK references', async () => {
+ const { user: admin } = createAdmin(testDb);
+ const { user: target } = createUser(testDb);
+ const { user: otherUser } = createUser(testDb);
+ const { user: thirdUser } = createUser(testDb);
+
+ // trip_members.invited_by: target invited thirdUser to otherUser's trip
+ // (trip survives deletion; only invited_by should become NULL)
+ const otherTrip = createTrip(testDb, otherUser.id);
+ testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(otherTrip.id, thirdUser.id, target.id);
+
+ // share_tokens.created_by: target created a share token for otherUser's trip
+ testDb.prepare("INSERT INTO share_tokens (trip_id, token, created_by) VALUES (?, 'tok-admin-test', ?)").run(otherTrip.id, target.id);
+
+ // budget_items.paid_by_user_id: target paid for an expense on otherUser's trip
+ const budgetItem = createBudgetItem(testDb, otherTrip.id);
+ testDb.prepare('UPDATE budget_items SET paid_by_user_id = ? WHERE id = ?').run(target.id, budgetItem.id);
+
+ // journey_contributors: target is a contributor on otherUser's journey
+ const otherJourney = createJourney(testDb, otherUser.id);
+ addJourneyContributor(testDb, otherJourney.id, target.id);
+
+ // journey_entries: target authored an entry on otherUser's journey
+ createJourneyEntry(testDb, otherJourney.id, target.id);
+
+ // journey_share_tokens: target created a share token for otherUser's journey
+ testDb.prepare("INSERT INTO journey_share_tokens (journey_id, token, created_by) VALUES (?, 'jst-admin-test', ?)").run(otherJourney.id, target.id);
+
+ // notifications.sender_id (SET NULL): target sent a notification to otherUser
+ const sentNotif = testDb.prepare(
+ "INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
+ ).run(otherTrip.id, target.id, otherUser.id);
+ // notifications.recipient_id (CASCADE): otherUser sent a notification to target
+ testDb.prepare(
+ "INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
+ ).run(otherTrip.id, otherUser.id, target.id);
+
+ // user_notice_dismissals (CASCADE): target dismissed a notice
+ testDb.prepare(
+ "INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, 'test-notice', ?)"
+ ).run(target.id, Date.now());
+
+ // owned journey: target owns a journey with an entry (cascade-deletes on journey deletion)
+ const ownedJourney = createJourney(testDb, target.id);
+ createJourneyEntry(testDb, ownedJourney.id, target.id);
+
+ // trip_files.uploaded_by (SET NULL): target uploaded a file to otherUser's trip
+ const fileRow = testDb.prepare(
+ "INSERT INTO trip_files (trip_id, filename, original_name, uploaded_by) VALUES (?, 'f.pdf', 'file.pdf', ?)"
+ ).run(otherTrip.id, target.id);
+
+ // trek_photos.owner_id (SET NULL): target owns a photo in the central registry
+ const trekPhotoRow = testDb.prepare(
+ "INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'asset-admin-test', ?)"
+ ).run(target.id);
+
+ // trip_photos.user_id (CASCADE): target added a photo to otherUser's trip
+ addTripPhoto(testDb, otherTrip.id, target.id, 'asset-tp-admin', 'immich');
+
+ // trips.user_id (CASCADE): target owns a trip
+ const ownedTrip = createTrip(testDb, target.id);
+
+ // trip_members.user_id (CASCADE): target is a member of otherUser's trip
+ addTripMember(testDb, otherTrip.id, target.id);
+
+ // categories.user_id (SET NULL): target created a category
+ const userCategory = createCategory(testDb, { user_id: target.id });
+
+ // tags.user_id (CASCADE): target created a tag
+ const userTag = createTag(testDb, target.id);
+
+ // todo_items.assigned_user_id (SET NULL): target is assigned to a todo on otherUser's trip
+ const todoItem = createTodoItem(testDb, otherTrip.id);
+ testDb.prepare('UPDATE todo_items SET assigned_user_id = ? WHERE id = ?').run(target.id, todoItem.id);
+
+ // packing_bags.user_id (SET NULL): target owns a packing bag on otherUser's trip
+ const packBagRow = testDb.prepare(
+ "INSERT INTO packing_bags (trip_id, name, color, user_id) VALUES (?, 'Bag', '#ff0000', ?)"
+ ).run(otherTrip.id, target.id);
+
+ // mcp_tokens.user_id (CASCADE): target has an MCP API token
+ createMcpToken(testDb, target.id);
+
+ // oauth_tokens/consents.user_id (CASCADE): target has tokens from otherUser's OAuth client
+ testDb.prepare(
+ "INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash) VALUES ('cl-admin-test', ?, 'App', 'cid-admin-test', 'h')"
+ ).run(otherUser.id);
+ testDb.prepare(
+ "INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, access_token_expires_at, refresh_token_expires_at) VALUES ('cid-admin-test', ?, 'ath-admin', 'rth-admin', datetime('now','+1 hour'), datetime('now','+30 days'))"
+ ).run(target.id);
+ testDb.prepare(
+ "INSERT INTO oauth_consents (client_id, user_id) VALUES ('cid-admin-test', ?)"
+ ).run(target.id);
+
+ // vacay_plans.owner_id (CASCADE): target owns a vacation plan
+ const vacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(target.id);
+
+ // vacay_plan_members.user_id (CASCADE): target is a member of otherUser's vacay plan
+ const otherVacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(otherUser.id);
+ testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id) VALUES (?, ?)").run(otherVacayPlanRow.lastInsertRowid, target.id);
+
+ // bucket_list.user_id (CASCADE): target has a bucket list item
+ createBucketListItem(testDb, target.id);
+
+ // visited_countries.user_id (CASCADE): target has visited a country
+ createVisitedCountry(testDb, target.id, 'JP');
+
+ // visited_regions.user_id (CASCADE): target has visited a region
+ testDb.prepare(
+ "INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, 'JP-13', 'Tokyo', 'JP')"
+ ).run(target.id);
+
+ // packing_templates.created_by (CASCADE): target created a packing template
+ const packTemplateRow = testDb.prepare(
+ "INSERT INTO packing_templates (name, created_by) VALUES ('My Template', ?)"
+ ).run(target.id);
+
+ // invite_tokens.created_by (CASCADE): target created an invite token
+ createInviteToken(testDb, { created_by: target.id });
+
+ // collab_notes.user_id (CASCADE): target authored a collab note on otherUser's trip
+ createCollabNote(testDb, otherTrip.id, target.id);
+
+ // settings.user_id (CASCADE): target has a user setting
+ testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(target.id);
+
+ // password_reset_tokens.user_id (CASCADE): target has a pending password reset
+ testDb.prepare(
+ "INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, 'prt-hash-admin', datetime('now','+1 hour'))"
+ ).run(target.id);
+
+ // audit_log.user_id (SET NULL): target performed an audited action
+ const auditRow = testDb.prepare(
+ "INSERT INTO audit_log (user_id, action, ip) VALUES (?, 'test.action', '127.0.0.1')"
+ ).run(target.id);
+
+ // notification_channel_preferences.user_id (CASCADE): target has notification preferences
+ testDb.prepare("INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel) VALUES (?, 'trip_invite', 'email')").run(target.id);
+
+ const res = await request(app)
+ .delete(`/api/admin/users/${target.id}`)
+ .set('Cookie', authCookie(admin.id));
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+
+ expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(target.id)).toBeUndefined();
+ // trip_members row survives but invited_by is now NULL
+ expect((testDb.prepare('SELECT invited_by FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, thirdUser.id) as any).invited_by).toBeNull();
+ expect(testDb.prepare('SELECT id FROM share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
+ expect((testDb.prepare('SELECT paid_by_user_id FROM budget_items WHERE id = ?').get(budgetItem.id) as any).paid_by_user_id).toBeNull();
+ expect(testDb.prepare('SELECT user_id FROM journey_contributors WHERE journey_id = ? AND user_id = ?').get(otherJourney.id, target.id)).toBeUndefined();
+ expect(testDb.prepare('SELECT id FROM journey_entries WHERE author_id = ?').get(target.id)).toBeUndefined();
+ expect(testDb.prepare('SELECT id FROM journey_share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
+ // sent notification survives but sender_id becomes NULL
+ expect((testDb.prepare('SELECT sender_id FROM notifications WHERE id = ?').get(sentNotif.lastInsertRowid) as any).sender_id).toBeNull();
+ // received notification is cascade-deleted
+ expect(testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(target.id)).toBeUndefined();
+ // notice dismissals are cascade-deleted
+ expect(testDb.prepare("SELECT user_id FROM user_notice_dismissals WHERE user_id = ? AND notice_id = 'test-notice'").get(target.id)).toBeUndefined();
+ // owned journey and its entries are cascade-deleted
+ expect(testDb.prepare('SELECT id FROM journeys WHERE user_id = ?').get(target.id)).toBeUndefined();
+ expect(testDb.prepare('SELECT id FROM journey_entries WHERE journey_id = ?').get(ownedJourney.id)).toBeUndefined();
+ // uploaded file survives but uploaded_by is now NULL
+ expect((testDb.prepare('SELECT uploaded_by FROM trip_files WHERE id = ?').get(fileRow.lastInsertRowid) as any).uploaded_by).toBeNull();
+ // trek_photos row survives but owner_id is now NULL
+ expect((testDb.prepare('SELECT owner_id FROM trek_photos WHERE id = ?').get(trekPhotoRow.lastInsertRowid) as any).owner_id).toBeNull();
+ // trip_photos row for target is cascade-deleted
+ expect(testDb.prepare("SELECT id FROM trip_photos WHERE trip_id = ? AND user_id = ?").get(otherTrip.id, target.id)).toBeUndefined();
+ // owned trip is cascade-deleted
+ expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(ownedTrip.id)).toBeUndefined();
+ // trip membership on others' trips is removed
+ expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, target.id)).toBeUndefined();
+ // category survives but user_id is NULL
+ expect((testDb.prepare('SELECT user_id FROM categories WHERE id = ?').get(userCategory.id) as any).user_id).toBeNull();
+ // tag is deleted
+ expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(userTag.id)).toBeUndefined();
+ // todo assigned_user_id is NULL
+ expect((testDb.prepare('SELECT assigned_user_id FROM todo_items WHERE id = ?').get(todoItem.id) as any).assigned_user_id).toBeNull();
+ // packing bag survives but user_id is NULL
+ expect((testDb.prepare('SELECT user_id FROM packing_bags WHERE id = ?').get(packBagRow.lastInsertRowid) as any).user_id).toBeNull();
+ // MCP tokens are deleted
+ expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
+ // OAuth tokens and consents are deleted
+ expect(testDb.prepare('SELECT id FROM oauth_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
+ expect(testDb.prepare('SELECT id FROM oauth_consents WHERE user_id = ?').get(target.id)).toBeUndefined();
+ // owned vacay plan is deleted
+ expect(testDb.prepare('SELECT id FROM vacay_plans WHERE id = ?').get(vacayPlanRow.lastInsertRowid)).toBeUndefined();
+ // vacay plan membership on others' plans is removed
+ expect(testDb.prepare('SELECT id FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(otherVacayPlanRow.lastInsertRowid, target.id)).toBeUndefined();
+ // bucket list items are deleted
+ expect(testDb.prepare('SELECT id FROM bucket_list WHERE user_id = ?').get(target.id)).toBeUndefined();
+ // travel history is deleted
+ expect(testDb.prepare('SELECT user_id FROM visited_countries WHERE user_id = ? AND country_code = ?').get(target.id, 'JP')).toBeUndefined();
+ expect(testDb.prepare('SELECT id FROM visited_regions WHERE user_id = ?').get(target.id)).toBeUndefined();
+ // packing template is deleted
+ expect(testDb.prepare('SELECT id FROM packing_templates WHERE id = ?').get(packTemplateRow.lastInsertRowid)).toBeUndefined();
+ // invite tokens created by target are deleted
+ expect(testDb.prepare('SELECT id FROM invite_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
+ // collab content is deleted
+ expect(testDb.prepare('SELECT id FROM collab_notes WHERE user_id = ? AND trip_id = ?').get(target.id, otherTrip.id)).toBeUndefined();
+ // user settings are deleted
+ expect(testDb.prepare("SELECT id FROM settings WHERE user_id = ?").get(target.id)).toBeUndefined();
+ // password reset tokens are deleted
+ expect(testDb.prepare('SELECT id FROM password_reset_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
+ // audit log entry survives but user_id is NULL
+ expect((testDb.prepare('SELECT user_id FROM audit_log WHERE id = ?').get(auditRow.lastInsertRowid) as any).user_id).toBeNull();
+ // notification channel preferences are deleted
+ expect(testDb.prepare("SELECT user_id FROM notification_channel_preferences WHERE user_id = ? AND event_type = 'trip_invite'").get(target.id)).toBeUndefined();
+ });
+
it('ADMIN-006 — admin cannot delete their own account', async () => {
const { user: admin } = createAdmin(testDb);
diff --git a/server/tests/integration/auth.test.ts b/server/tests/integration/auth.test.ts
index deb9df5a..d60dbc0e 100644
--- a/server/tests/integration/auth.test.ts
+++ b/server/tests/integration/auth.test.ts
@@ -52,7 +52,7 @@ import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
-import { createUser, createAdmin, createUserWithMfa, createInviteToken } from '../helpers/factories';
+import { createUser, createAdmin, createUserWithMfa, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories';
import { authCookie, authHeader } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
@@ -509,6 +509,225 @@ describe('Extended auth scenarios', () => {
});
});
+// ─────────────────────────────────────────────────────────────────────────────
+// Account deletion
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('Account deletion', () => {
+ it('AUTH-040 — DELETE /auth/me succeeds when user has FK references', async () => {
+ const { user: admin } = createAdmin(testDb);
+ const { user: target } = createUser(testDb);
+ const { user: otherUser } = createUser(testDb);
+ const { user: thirdUser } = createUser(testDb);
+
+ // trip_members.invited_by: target invited thirdUser to otherUser's trip
+ // (trip survives deletion; only invited_by should become NULL)
+ const otherTrip = createTrip(testDb, otherUser.id);
+ testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(otherTrip.id, thirdUser.id, target.id);
+
+ // share_tokens.created_by: target created a share token for otherUser's trip
+ testDb.prepare("INSERT INTO share_tokens (trip_id, token, created_by) VALUES (?, 'tok-auth-test', ?)").run(otherTrip.id, target.id);
+
+ // budget_items.paid_by_user_id: target paid for an expense on otherUser's trip
+ const budgetItem = createBudgetItem(testDb, otherTrip.id);
+ testDb.prepare('UPDATE budget_items SET paid_by_user_id = ? WHERE id = ?').run(target.id, budgetItem.id);
+
+ // journey_contributors: target is a contributor on otherUser's journey
+ const otherJourney = createJourney(testDb, otherUser.id);
+ addJourneyContributor(testDb, otherJourney.id, target.id);
+
+ // journey_entries: target authored an entry on otherUser's journey
+ createJourneyEntry(testDb, otherJourney.id, target.id);
+
+ // journey_share_tokens: target created a share token for otherUser's journey
+ testDb.prepare("INSERT INTO journey_share_tokens (journey_id, token, created_by) VALUES (?, 'jst-auth-test', ?)").run(otherJourney.id, target.id);
+
+ // notifications.sender_id (SET NULL): target sent a notification to otherUser
+ const sentNotif = testDb.prepare(
+ "INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
+ ).run(otherTrip.id, target.id, otherUser.id);
+ // notifications.recipient_id (CASCADE): otherUser sent a notification to target
+ testDb.prepare(
+ "INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
+ ).run(otherTrip.id, otherUser.id, target.id);
+
+ // user_notice_dismissals (CASCADE): target dismissed a notice
+ testDb.prepare(
+ "INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, 'test-notice', ?)"
+ ).run(target.id, Date.now());
+
+ // owned journey: target owns a journey with an entry (cascade-deletes on journey deletion)
+ const ownedJourney = createJourney(testDb, target.id);
+ createJourneyEntry(testDb, ownedJourney.id, target.id);
+
+ // trip_files.uploaded_by (SET NULL): target uploaded a file to otherUser's trip
+ const fileRow = testDb.prepare(
+ "INSERT INTO trip_files (trip_id, filename, original_name, uploaded_by) VALUES (?, 'f.pdf', 'file.pdf', ?)"
+ ).run(otherTrip.id, target.id);
+
+ // trek_photos.owner_id (SET NULL): target owns a photo in the central registry
+ const trekPhotoRow = testDb.prepare(
+ "INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'asset-auth-test', ?)"
+ ).run(target.id);
+
+ // trip_photos.user_id (CASCADE): target added a photo to otherUser's trip
+ addTripPhoto(testDb, otherTrip.id, target.id, 'asset-tp-auth', 'immich');
+
+ // trips.user_id (CASCADE): target owns a trip
+ const ownedTrip = createTrip(testDb, target.id);
+
+ // trip_members.user_id (CASCADE): target is a member of otherUser's trip
+ addTripMember(testDb, otherTrip.id, target.id);
+
+ // categories.user_id (SET NULL): target created a category
+ const userCategory = createCategory(testDb, { user_id: target.id });
+
+ // tags.user_id (CASCADE): target created a tag
+ const userTag = createTag(testDb, target.id);
+
+ // todo_items.assigned_user_id (SET NULL): target is assigned to a todo on otherUser's trip
+ const todoItem = createTodoItem(testDb, otherTrip.id);
+ testDb.prepare('UPDATE todo_items SET assigned_user_id = ? WHERE id = ?').run(target.id, todoItem.id);
+
+ // packing_bags.user_id (SET NULL): target owns a packing bag on otherUser's trip
+ const packBagRow = testDb.prepare(
+ "INSERT INTO packing_bags (trip_id, name, color, user_id) VALUES (?, 'Bag', '#ff0000', ?)"
+ ).run(otherTrip.id, target.id);
+
+ // mcp_tokens.user_id (CASCADE): target has an MCP API token
+ createMcpToken(testDb, target.id);
+
+ // oauth_tokens/consents.user_id (CASCADE): target has tokens from otherUser's OAuth client
+ testDb.prepare(
+ "INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash) VALUES ('cl-auth-test', ?, 'App', 'cid-auth-test', 'h')"
+ ).run(otherUser.id);
+ testDb.prepare(
+ "INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, access_token_expires_at, refresh_token_expires_at) VALUES ('cid-auth-test', ?, 'ath-auth', 'rth-auth', datetime('now','+1 hour'), datetime('now','+30 days'))"
+ ).run(target.id);
+ testDb.prepare(
+ "INSERT INTO oauth_consents (client_id, user_id) VALUES ('cid-auth-test', ?)"
+ ).run(target.id);
+
+ // vacay_plans.owner_id (CASCADE): target owns a vacation plan
+ const vacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(target.id);
+
+ // vacay_plan_members.user_id (CASCADE): target is a member of otherUser's vacay plan
+ const otherVacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(otherUser.id);
+ testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id) VALUES (?, ?)").run(otherVacayPlanRow.lastInsertRowid, target.id);
+
+ // bucket_list.user_id (CASCADE): target has a bucket list item
+ createBucketListItem(testDb, target.id);
+
+ // visited_countries.user_id (CASCADE): target has visited a country
+ createVisitedCountry(testDb, target.id, 'JP');
+
+ // visited_regions.user_id (CASCADE): target has visited a region
+ testDb.prepare(
+ "INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, 'JP-13', 'Tokyo', 'JP')"
+ ).run(target.id);
+
+ // packing_templates.created_by (CASCADE): target created a packing template
+ const packTemplateRow = testDb.prepare(
+ "INSERT INTO packing_templates (name, created_by) VALUES ('My Template', ?)"
+ ).run(target.id);
+
+ // invite_tokens.created_by (CASCADE): target created an invite token
+ createInviteToken(testDb, { created_by: target.id });
+
+ // collab_notes.user_id (CASCADE): target authored a collab note on otherUser's trip
+ createCollabNote(testDb, otherTrip.id, target.id);
+
+ // settings.user_id (CASCADE): target has a user setting
+ testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(target.id);
+
+ // password_reset_tokens.user_id (CASCADE): target has a pending password reset
+ testDb.prepare(
+ "INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, 'prt-hash-auth', datetime('now','+1 hour'))"
+ ).run(target.id);
+
+ // audit_log.user_id (SET NULL): target performed an audited action
+ const auditRow = testDb.prepare(
+ "INSERT INTO audit_log (user_id, action, ip) VALUES (?, 'test.action', '127.0.0.1')"
+ ).run(target.id);
+
+ // notification_channel_preferences.user_id (CASCADE): target has notification preferences
+ testDb.prepare("INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel) VALUES (?, 'trip_invite', 'email')").run(target.id);
+
+ // admin exists to ensure target (non-admin user) passes the last-admin guard
+ void admin;
+
+ const res = await request(app)
+ .delete('/api/auth/me')
+ .set('Cookie', authCookie(target.id));
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+
+ expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(target.id)).toBeUndefined();
+ // trip_members row survives but invited_by is now NULL
+ expect((testDb.prepare('SELECT invited_by FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, thirdUser.id) as any).invited_by).toBeNull();
+ expect(testDb.prepare('SELECT id FROM share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
+ expect((testDb.prepare('SELECT paid_by_user_id FROM budget_items WHERE id = ?').get(budgetItem.id) as any).paid_by_user_id).toBeNull();
+ expect(testDb.prepare('SELECT user_id FROM journey_contributors WHERE journey_id = ? AND user_id = ?').get(otherJourney.id, target.id)).toBeUndefined();
+ expect(testDb.prepare('SELECT id FROM journey_entries WHERE author_id = ?').get(target.id)).toBeUndefined();
+ expect(testDb.prepare('SELECT id FROM journey_share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
+ // sent notification survives but sender_id becomes NULL
+ expect((testDb.prepare('SELECT sender_id FROM notifications WHERE id = ?').get(sentNotif.lastInsertRowid) as any).sender_id).toBeNull();
+ // received notification is cascade-deleted
+ expect(testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(target.id)).toBeUndefined();
+ // notice dismissals are cascade-deleted
+ expect(testDb.prepare("SELECT user_id FROM user_notice_dismissals WHERE user_id = ? AND notice_id = 'test-notice'").get(target.id)).toBeUndefined();
+ // owned journey and its entries are cascade-deleted
+ expect(testDb.prepare('SELECT id FROM journeys WHERE user_id = ?').get(target.id)).toBeUndefined();
+ expect(testDb.prepare('SELECT id FROM journey_entries WHERE journey_id = ?').get(ownedJourney.id)).toBeUndefined();
+ // uploaded file survives but uploaded_by is now NULL
+ expect((testDb.prepare('SELECT uploaded_by FROM trip_files WHERE id = ?').get(fileRow.lastInsertRowid) as any).uploaded_by).toBeNull();
+ // trek_photos row survives but owner_id is now NULL
+ expect((testDb.prepare('SELECT owner_id FROM trek_photos WHERE id = ?').get(trekPhotoRow.lastInsertRowid) as any).owner_id).toBeNull();
+ // trip_photos row for target is cascade-deleted
+ expect(testDb.prepare("SELECT id FROM trip_photos WHERE trip_id = ? AND user_id = ?").get(otherTrip.id, target.id)).toBeUndefined();
+ // owned trip is cascade-deleted
+ expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(ownedTrip.id)).toBeUndefined();
+ // trip membership on others' trips is removed
+ expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, target.id)).toBeUndefined();
+ // category survives but user_id is NULL
+ expect((testDb.prepare('SELECT user_id FROM categories WHERE id = ?').get(userCategory.id) as any).user_id).toBeNull();
+ // tag is deleted
+ expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(userTag.id)).toBeUndefined();
+ // todo assigned_user_id is NULL
+ expect((testDb.prepare('SELECT assigned_user_id FROM todo_items WHERE id = ?').get(todoItem.id) as any).assigned_user_id).toBeNull();
+ // packing bag survives but user_id is NULL
+ expect((testDb.prepare('SELECT user_id FROM packing_bags WHERE id = ?').get(packBagRow.lastInsertRowid) as any).user_id).toBeNull();
+ // MCP tokens are deleted
+ expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
+ // OAuth tokens and consents are deleted
+ expect(testDb.prepare('SELECT id FROM oauth_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
+ expect(testDb.prepare('SELECT id FROM oauth_consents WHERE user_id = ?').get(target.id)).toBeUndefined();
+ // owned vacay plan is deleted
+ expect(testDb.prepare('SELECT id FROM vacay_plans WHERE id = ?').get(vacayPlanRow.lastInsertRowid)).toBeUndefined();
+ // vacay plan membership on others' plans is removed
+ expect(testDb.prepare('SELECT id FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(otherVacayPlanRow.lastInsertRowid, target.id)).toBeUndefined();
+ // bucket list items are deleted
+ expect(testDb.prepare('SELECT id FROM bucket_list WHERE user_id = ?').get(target.id)).toBeUndefined();
+ // travel history is deleted
+ expect(testDb.prepare('SELECT user_id FROM visited_countries WHERE user_id = ? AND country_code = ?').get(target.id, 'JP')).toBeUndefined();
+ expect(testDb.prepare('SELECT id FROM visited_regions WHERE user_id = ?').get(target.id)).toBeUndefined();
+ // packing template is deleted
+ expect(testDb.prepare('SELECT id FROM packing_templates WHERE id = ?').get(packTemplateRow.lastInsertRowid)).toBeUndefined();
+ // invite tokens created by target are deleted
+ expect(testDb.prepare('SELECT id FROM invite_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
+ // collab content is deleted
+ expect(testDb.prepare('SELECT id FROM collab_notes WHERE user_id = ? AND trip_id = ?').get(target.id, otherTrip.id)).toBeUndefined();
+ // user settings are deleted
+ expect(testDb.prepare("SELECT id FROM settings WHERE user_id = ?").get(target.id)).toBeUndefined();
+ // password reset tokens are deleted
+ expect(testDb.prepare('SELECT id FROM password_reset_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
+ // audit log entry survives but user_id is NULL
+ expect((testDb.prepare('SELECT user_id FROM audit_log WHERE id = ?').get(auditRow.lastInsertRowid) as any).user_id).toBeNull();
+ // notification channel preferences are deleted
+ expect(testDb.prepare("SELECT user_id FROM notification_channel_preferences WHERE user_id = ? AND event_type = 'trip_invite'").get(target.id)).toBeUndefined();
+ });
+});
+
// ─────────────────────────────────────────────────────────────────────────────
// Rate limiting (AUTH-004, AUTH-018) — placed last
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/server/tests/unit/services/tripService.test.ts b/server/tests/unit/services/tripService.test.ts
index 12c5a13d..795dbcb2 100644
--- a/server/tests/unit/services/tripService.test.ts
+++ b/server/tests/unit/services/tripService.test.ts
@@ -96,33 +96,37 @@ describe('generateDays', () => {
expect(getNotes(day2.id)[0].id).toBe(note.id);
});
- it('TRIP-SVC-011: shrinking range converts overflow days to dateless, preserves their assignments', () => {
+ it('TRIP-SVC-011: shrinking range deletes overflow days and their assignments (issue #909)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' });
const daysBefore = getDays(trip.id);
expect(daysBefore).toHaveLength(5);
const place = createPlace(testDb, trip.id);
- // Assign places to days 4 and 5 (will become overflow)
- const a4 = createDayAssignment(testDb, daysBefore[3].id, place.id);
- const a5 = createDayAssignment(testDb, daysBefore[4].id, place.id);
+ createDayAssignment(testDb, daysBefore[3].id, place.id);
+ createDayAssignment(testDb, daysBefore[4].id, place.id);
- // Shrink from 5 to 3 days
+ // Shrink from 5 to 3 days — surplus days and their content are removed
generateDays(trip.id, '2025-07-01', '2025-07-03');
const daysAfter = getDays(trip.id);
- expect(daysAfter).toHaveLength(5); // no rows deleted
+ expect(daysAfter).toHaveLength(3);
+ expect(daysAfter.map(d => d.date)).toEqual(['2025-07-01', '2025-07-02', '2025-07-03']);
+ });
- const dated = daysAfter.filter(d => d.date !== null);
- const dateless = daysAfter.filter(d => d.date === null);
- expect(dated).toHaveLength(3);
- expect(dateless).toHaveLength(2);
+ it('TRIP-SVC-016: shrinking range deletes empty overflow days (issue #909)', () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-07' });
+ expect(getDays(trip.id)).toHaveLength(7);
- // Overflow days still have their assignments
- expect(getAssignments(dateless[0].id)).toHaveLength(1);
- expect(getAssignments(dateless[0].id)[0].id).toBe(a4.id);
- expect(getAssignments(dateless[1].id)).toHaveLength(1);
- expect(getAssignments(dateless[1].id)[0].id).toBe(a5.id);
+ // Shrink 7 → 5; days 6 and 7 have no content
+ generateDays(trip.id, '2025-07-01', '2025-07-05');
+
+ const daysAfter = getDays(trip.id);
+ expect(daysAfter).toHaveLength(5);
+ expect(daysAfter.map(d => d.date)).toEqual([
+ '2025-07-01', '2025-07-02', '2025-07-03', '2025-07-04', '2025-07-05',
+ ]);
});
it('TRIP-SVC-012: growing range keeps existing day content and appends new empty days', () => {