mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Bug fixes - April 27th 2026 (#907)
* fix: clean up dangling FK references before deleting a user Resolves FOREIGN KEY constraint failed (500) on DELETE /api/admin/users/:id and DELETE /api/auth/me when the target user had rows in trip_members.invited_by, share_tokens.created_by, budget_items.paid_by_user_id, journeys.user_id, journey_entries.author_id, journey_contributors.user_id, or journey_share_tokens.created_by — none of which had ON DELETE clauses. Introduces deleteUserCompletely() in userCleanupService.ts which wraps all cleanup and the final DELETE FROM users in a single transaction. Both adminService.deleteUser and authService.deleteAccount now call it instead of the bare DELETE. Tests ADMIN-005b and AUTH-040 cover all reference types including notification sender/recipient and notice dismissals. * test: extend FK deletion tests to cover journeys, files, and photos ADMIN-005b and AUTH-040 now also seed and assert: - owned journey with entries (cascade-deleted via journeys.user_id cleanup) - trip_files.uploaded_by (SET NULL — file survives, attribution cleared) - trek_photos.owner_id (SET NULL — photo record survives, owner cleared) - trip_photos.user_id (CASCADE — photo association removed) * test: extend user deletion tests to cover all FK relationships ADMIN-005b and AUTH-040 now seed and assert every user FK relationship: CASCADE (row deleted): trips, trip_members, tags, mcp_tokens, oauth_tokens, oauth_consents, vacay_plans, vacay_plan_members, bucket_list, visited_countries, visited_regions, packing_templates, invite_tokens, collab_notes, settings, password_reset_tokens, notification_channel_preferences SET NULL (row survives, column nulled): categories, todo_items.assigned_user_id, packing_bags, audit_log Caught and fixed: notification_preferences was dropped in migration 72; correct table is notification_channel_preferences. * fix: preserve URL hash and OIDC redirect target through login flow - Include location.hash in redirect param at all three producer sites (ProtectedRoute, axios 401 interceptor, OAuthAuthorizePage) so hash fragments survive the login bounce - Stash redirectTarget in sessionStorage before any OIDC provider redirect and restore it after the code exchange, since the IdP strips the original ?redirect= param during the roundtrip - Clear sessionStorage on OIDC error to avoid stale state - Add tests covering sessionStorage stash on mount, navigate to saved redirect after OIDC exchange, fallback to /dashboard, and cleanup on error * fix: use day position instead of ID for accommodation date range clamping Math.min/Math.max over raw day IDs breaks the start/end picker when a trip's day IDs are non-monotonic relative to day_number (normal after repeated generateDays extend/shrink cycles). Replaced with findIndex lookups so clamping is always based on positional order. Closes #889 * fix: normalize env var comparisons to be case-insensitive All NODE_ENV, DEMO_MODE, OIDC_ONLY, FORCE_HTTPS, COOKIE_SECURE, and ALLOW_INTERNAL_NETWORK checks now use .toLowerCase() so values like 'Production' or 'True' behave identically to their lowercase forms. Also adds APP_VERSION to the startup banner. * fix: delete surplus days when shortening a trip When shrinking a trip's date range, surplus days are now deleted along with their assignments, notes, and accommodations (cascade). Places remain in the trip pool; reservations keep their day reference nulled by the existing ON DELETE SET NULL constraint (issue #909). Updates TRIP-SVC-011 to reflect the new behaviour; adds TRIP-SVC-016 as a regression test for the empty-day case. * fix: auto-backup retention deletes itself and manual backups on Docker Two bugs in cleanupOldBackups: 1. Filter was .endsWith('.zip') — swept manual backup-*.zip files too. Now restricted to auto-backup-* prefix. 2. Age was derived from stat.birthtimeMs, which is 0 on overlayfs (Docker default), making every backup appear epoch-old and get deleted immediately. Age is now parsed from the filename timestamp and falls back to mtimeMs (reliable on overlayfs). Also converts inline require('./services/auditLog') calls to a static import throughout scheduler.ts, and adds 8 unit tests covering the fixed retention logic including the overlayfs regression case. * test: update TRIP-024 to match delete behavior on trip shrink * feat: add bypass-branch-check label to skip branch enforcement
This commit is contained in:
+1
-1
@@ -58,7 +58,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
const redirectParam = encodeURIComponent(location.pathname + location.search)
|
||||
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
|
||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ apiClient.interceptors.response.use(
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname)) {
|
||||
const currentPath = pathname + window.location.search
|
||||
const currentPath = pathname + window.location.search + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -892,6 +892,183 @@ describe('DayDetailPanel', () => {
|
||||
expect(screen.getByText(/June|15/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Accommodation date-range picker — non-monotonic day IDs (issue #889) ─────
|
||||
|
||||
// Builds the reporter's exact ID layout: day_number 1-9 → IDs 17-25, day_number 10-16 → IDs 1-7.
|
||||
// This happens after repeated trip-length changes via generateDays (no import/migration needed).
|
||||
function buildNonMonotonicDays() {
|
||||
return [
|
||||
buildDay({ id: 17, trip_id: 1, date: '2026-04-30' }),
|
||||
buildDay({ id: 18, trip_id: 1, date: '2026-05-01' }),
|
||||
buildDay({ id: 19, trip_id: 1, date: '2026-05-02' }),
|
||||
buildDay({ id: 20, trip_id: 1, date: '2026-05-03' }),
|
||||
buildDay({ id: 21, trip_id: 1, date: '2026-05-04' }),
|
||||
buildDay({ id: 22, trip_id: 1, date: '2026-05-05' }),
|
||||
buildDay({ id: 23, trip_id: 1, date: '2026-05-06' }),
|
||||
buildDay({ id: 24, trip_id: 1, date: '2026-05-07' }),
|
||||
buildDay({ id: 25, trip_id: 1, date: '2026-05-08' }),
|
||||
buildDay({ id: 1, trip_id: 1, date: '2026-05-09' }),
|
||||
buildDay({ id: 2, trip_id: 1, date: '2026-05-10' }),
|
||||
buildDay({ id: 3, trip_id: 1, date: '2026-05-11' }),
|
||||
buildDay({ id: 4, trip_id: 1, date: '2026-05-12' }),
|
||||
buildDay({ id: 5, trip_id: 1, date: '2026-05-13' }),
|
||||
buildDay({ id: 6, trip_id: 1, date: '2026-05-14' }),
|
||||
buildDay({ id: 7, trip_id: 1, date: '2026-05-15' }),
|
||||
];
|
||||
}
|
||||
|
||||
// Returns the two CustomSelect trigger buttons for start/end day pickers.
|
||||
// When no dropdown is open, these are the only globally-visible buttons whose textContent
|
||||
// matches /Day \d+/ (the main panel title is a div, not a button).
|
||||
// [0] = start trigger, [1] = end trigger (DOM source order).
|
||||
function getDayPickerTriggers() {
|
||||
return screen.getAllByRole('button').filter(b => /Day \d+/.test(b.textContent ?? ''));
|
||||
}
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-056: non-monotonic IDs — end picker does not clobber start-day', async () => {
|
||||
const days = buildNonMonotonicDays();
|
||||
const place = buildPlace({ id: 50, name: 'Range Hotel' });
|
||||
let capturedBody: any;
|
||||
server.use(
|
||||
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||
capturedBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
accommodation: {
|
||||
id: 99, place_id: 50, place_name: 'Range Hotel', place_address: null,
|
||||
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
|
||||
check_in: null, check_out: null, confirmation: null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Range Hotel/i }));
|
||||
|
||||
// Both triggers show "Day 1"; the second one is the end picker.
|
||||
await userEvent.click(getDayPickerTriggers()[1]);
|
||||
// Select "Day 16" (id=7) from the open dropdown — textContent starts with "Day 16".
|
||||
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
// start must remain id 17 (day 1) — old code would clobber it to id 7 via Math.min
|
||||
expect(capturedBody?.start_day_id).toBe(17);
|
||||
expect(capturedBody?.end_day_id).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-057: non-monotonic IDs — start picker does not collapse end when start has high ID', async () => {
|
||||
const days = buildNonMonotonicDays();
|
||||
const place = buildPlace({ id: 51, name: 'Span Hotel' });
|
||||
let capturedBody: any;
|
||||
server.use(
|
||||
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||
capturedBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
accommodation: {
|
||||
id: 100, place_id: 51, place_name: 'Span Hotel', place_address: null,
|
||||
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
|
||||
check_in: null, check_out: null, confirmation: null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Span Hotel/i }));
|
||||
|
||||
// Set end to day 16 (id=7, low ID but last day by position).
|
||||
await userEvent.click(getDayPickerTriggers()[1]);
|
||||
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||
|
||||
// Set start to day 9 (id=25, high ID, but earlier by position than day 16).
|
||||
// Old code: Math.max(25, 7) = 25 → end collapses to day 9.
|
||||
// New code: position(id=25)=8 < position(id=7)=15 → end stays at 7 (day 16).
|
||||
await userEvent.click(getDayPickerTriggers()[0]);
|
||||
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 9'))!);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody?.start_day_id).toBe(25); // day 9
|
||||
expect(capturedBody?.end_day_id).toBe(7); // day 16 — must NOT have collapsed
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-058: non-monotonic IDs — All days button sets correct first/last IDs', async () => {
|
||||
const days = buildNonMonotonicDays();
|
||||
const place = buildPlace({ id: 52, name: 'Full Trip Hotel' });
|
||||
let capturedBody: any;
|
||||
server.use(
|
||||
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||
capturedBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
accommodation: {
|
||||
id: 101, place_id: 52, place_name: 'Full Trip Hotel', place_address: null,
|
||||
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
|
||||
check_in: null, check_out: null, confirmation: null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Full Trip Hotel/i }));
|
||||
|
||||
// "All" is the day.allDays translation (en: "All") — the Apply-to-entire-trip button.
|
||||
// When categories=[] the category-filter "All" button is not rendered, so this is unique.
|
||||
await userEvent.click(screen.getByRole('button', { name: /^All$/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
// days[0].id=17 (first by position), days[15].id=7 (last by position)
|
||||
expect(capturedBody?.start_day_id).toBe(17);
|
||||
expect(capturedBody?.end_day_id).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-059: sequential IDs — end picker clamping still works (regression guard)', async () => {
|
||||
const seqDays = [
|
||||
buildDay({ id: 101, trip_id: 1, date: '2026-06-01' }),
|
||||
buildDay({ id: 102, trip_id: 1, date: '2026-06-02' }),
|
||||
buildDay({ id: 103, trip_id: 1, date: '2026-06-03' }),
|
||||
];
|
||||
const place = buildPlace({ id: 53, name: 'Seq Hotel' });
|
||||
let capturedBody: any;
|
||||
server.use(
|
||||
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||
capturedBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
accommodation: {
|
||||
id: 102, place_id: 53, place_name: 'Seq Hotel', place_address: null,
|
||||
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
|
||||
check_in: null, check_out: null, confirmation: null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<DayDetailPanel {...defaultProps} day={seqDays[0]} days={seqDays} places={[place]} />);
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Seq Hotel/i }));
|
||||
|
||||
// Pick end = day 3 (id=103, position 2 > position 0 of start id=101).
|
||||
await userEvent.click(getDayPickerTriggers()[1]);
|
||||
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 3'))!);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody?.start_day_id).toBe(101);
|
||||
expect(capturedBody?.end_day_id).toBe(103);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => {
|
||||
seedStore(useSettingsStore, {
|
||||
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false },
|
||||
|
||||
@@ -463,7 +463,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<CustomSelect
|
||||
value={hotelDayRange.start}
|
||||
onChange={v => 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
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<CustomSelect
|
||||
value={hotelDayRange.end}
|
||||
onChange={v => 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 }),
|
||||
|
||||
@@ -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(<LoginPage />);
|
||||
|
||||
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(<LoginPage />);
|
||||
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(<LoginPage />);
|
||||
|
||||
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(<LoginPage />);
|
||||
|
||||
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(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user