+ {t(
+ 'settings.analytics.customCspWarningText',
+ 'PicPeak ships with a strict CSP (`script-src \'self\'`). If your tracker loads from another domain, add that domain to your reverse-proxy or nginx CSP config — otherwise the browser silently blocks the script.',
+ )}
+
- {t(
- 'settings.analytics.customCspWarningText',
- 'PicPeak ships with a strict CSP (`script-src \'self\'`). If your tracker loads from another domain, add that domain to your reverse-proxy or nginx CSP config — otherwise the browser silently blocks the script.',
- )}
-
-
-
-
+
)}
From c2428aa23a77c7f4910f39066ebe69c12dac6125 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Tue, 1 Sep 2026 16:28:21 +0200
Subject: [PATCH 05/33] fix(events): render a not-found state instead of
hanging on a 404
EventDetailsPage gated on `if (eventLoading || !event)`. The backend returns
a clean 404 for a nonexistent id, but once isLoading settled false `event`
stayed undefined forever, so /admin/events/999999 sat on the loading spinner
permanently with no error state.
Destructure isError and split the gate: spinner while loading, then a
not-found Card. Reuses the existing `events.notFound` key (already used by
EventFeedbackPage for the same entity) and the Card padding="lg" not-found
shape from contracts/ContractDetailPage. No new i18n keys.
Refs testplan REPORT.md #5 (Part 7.02).
---
frontend/src/pages/admin/EventDetailsPage.tsx | 19 ++++-
.../__tests__/eventDetailsNotFound.test.tsx | 84 +++++++++++++++++++
2 files changed, 100 insertions(+), 3 deletions(-)
create mode 100644 frontend/src/pages/admin/__tests__/eventDetailsNotFound.test.tsx
diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx
index 5c5b3823..d9c58e40 100644
--- a/frontend/src/pages/admin/EventDetailsPage.tsx
+++ b/frontend/src/pages/admin/EventDetailsPage.tsx
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
-import { Loading } from '../../components/common';
+import { Button, Card, Loading } from '../../components/common';
import { PasswordResetModal, PublishGalleryDialog, SendGalleryEmailDialog, DuplicateEventDialog, EventRenameDialog, AdminGuestsList } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
@@ -101,7 +101,7 @@ export const EventDetailsPage: React.FC = () => {
});
// Fetch event details
- const { data: event, isLoading: eventLoading, refetch: refetchEvent } = useQuery({
+ const { data: event, isLoading: eventLoading, isError: eventError, refetch: refetchEvent } = useQuery({
queryKey: ['admin-event', id],
queryFn: () => eventsService.getEvent(parseInt(id!)),
enabled: !!id,
@@ -324,7 +324,7 @@ export const EventDetailsPage: React.FC = () => {
},
});
- if (eventLoading || !event) {
+ if (eventLoading) {
return (
@@ -332,6 +332,19 @@ export const EventDetailsPage: React.FC = () => {
);
}
+ // A 404 (or any settled failure) leaves `event` undefined forever — without
+ // this branch the spinner above never resolved (QA 7.02).
+ if (eventError || !event) {
+ return (
+
+
{t('events.notFound', 'Event not found')}
+
+
+ );
+ }
+
const expiresAtDate = safeParseDate(event.expires_at);
// Timestamp comparison, not truncated whole days (#909): the old
// differenceInDays <= 0 marked events "expired" up to 24h early.
diff --git a/frontend/src/pages/admin/__tests__/eventDetailsNotFound.test.tsx b/frontend/src/pages/admin/__tests__/eventDetailsNotFound.test.tsx
new file mode 100644
index 00000000..621f7cdb
--- /dev/null
+++ b/frontend/src/pages/admin/__tests__/eventDetailsNotFound.test.tsx
@@ -0,0 +1,84 @@
+/**
+ * /admin/events/:id hung on the spinner forever for a nonexistent id
+ * (QA 7.02). The backend returns a clean 404, but the page gated on
+ * `eventLoading || !event`, so once the query settled `event` stayed
+ * undefined and the condition never went false.
+ */
+import React from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+vi.mock('react-i18next', async () => {
+ const actual = await vi.importActual('react-i18next');
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k),
+ i18n: { language: 'en' },
+ }),
+ };
+});
+
+vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
+
+const getEvent = vi.fn();
+vi.mock('../../../services/events.service', () => ({
+ eventsService: {
+ getEvent: (...args: unknown[]) => getEvent(...args),
+ updateEvent: vi.fn(),
+ deleteEvent: vi.fn(),
+ extendExpiration: vi.fn(),
+ duplicateEvent: vi.fn(),
+ resetPassword: vi.fn(),
+ publishEvent: vi.fn(),
+ renameEvent: vi.fn(),
+ },
+}));
+
+vi.mock('../../../hooks/usePublicSettings', () => ({
+ PUBLIC_SETTINGS_QUERY_KEY: ['public-settings'],
+ usePublicSettings: () => ({ data: {} }),
+}));
+
+vi.mock('../../../contexts/FeatureFlagsContext', () => ({
+ useFeatureFlags: () => ({ flags: {}, isLoading: false }),
+ useFeatureEnabled: () => false,
+}));
+
+vi.mock('../../../contexts/PermissionsContext', () => ({
+ usePermissions: () => ({ hasAnyPermission: () => true, hasPermission: () => true, isLoading: false }),
+}));
+
+import { EventDetailsPage } from '../EventDetailsPage';
+
+function renderPage() {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+
+ } />
+ events list
} />
+
+
+
+ );
+}
+
+describe('EventDetailsPage 404 handling (QA 7.02)', () => {
+ it('renders a not-found state instead of spinning forever when the event 404s', async () => {
+ getEvent.mockRejectedValue({ response: { status: 404, data: { error: 'Event not found' } } });
+
+ renderPage();
+
+ expect(screen.getByText('events.loadingEventDetails')).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(screen.getByText('Event not found')).toBeInTheDocument();
+ });
+ expect(screen.queryByText('events.loadingEventDetails')).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'events.backToEvents' })).toBeInTheDocument();
+ });
+});
From 673f05556d0f33e59a325714e5a2679a3c4356b6 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Tue, 1 Sep 2026 16:28:21 +0200
Subject: [PATCH 06/33] fix(settings): don't crash on a fresh load before
permissions resolve
On a hard navigation or deep link, usePermissions() starts out empty, which
filters every settings nav group down to nothing. allItems is then [], so
`allItems.find(...) ?? allItems[0]` yields undefined and ``
threw -- sometimes into the error boundary, sometimes racing past it.
Reproduced 6+ times across the webhooks/moderation/slideshow/security/events
tabs; in-app SPA navigation never hit it.
Extend the file's existing early-return to `isLoading || permissionsLoading`.
activeTab lives in useState seeded from ?tab= at mount, independent of the
gate, so deep links still land on the right tab once permissions arrive.
Also null-guard activeItem before the section heading: a role holding zero
settings-tab permissions crashes identically even after permissions finish
loading, which the loading gate alone does not cover.
Refs testplan REPORT.md #11 (Part 3, J.08).
---
frontend/src/pages/admin/SettingsPage.tsx | 10 +-
.../__tests__/settingsPageMountRace.test.tsx | 110 ++++++++++++++++++
2 files changed, 117 insertions(+), 3 deletions(-)
create mode 100644 frontend/src/pages/admin/__tests__/settingsPageMountRace.test.tsx
diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx
index 4337bd04..0450592f 100644
--- a/frontend/src/pages/admin/SettingsPage.tsx
+++ b/frontend/src/pages/admin/SettingsPage.tsx
@@ -170,7 +170,7 @@ export const SettingsPage: React.FC = () => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const { flags, isLoading: flagsLoading } = useFeatureFlags();
- const { hasAnyPermission } = usePermissions();
+ const { hasAnyPermission, isLoading: permissionsLoading } = usePermissions();
// Read ?tab=… on mount; default to Features per the redesign.
const initialTab: TabType = isValidTab(searchParams.get('tab'))
@@ -288,7 +288,11 @@ export const SettingsPage: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flagsLoading, activeTab, flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, flags.whatsapp, flags.slideshow]);
- if (isLoading) {
+ // Wait for the permissions context too: on a fresh/hard mount it starts out
+ // empty, which filters every nav group down to nothing and left `activeItem`
+ // undefined below (QA J.08 crash). `activeTab` is held in state, so a
+ // deep-linked ?tab= still lands on the right tab once permissions arrive.
+ if (isLoading || permissionsLoading) {
return (
{/* Section heading icon stays neutral so the Settings
diff --git a/frontend/src/pages/admin/__tests__/settingsPageMountRace.test.tsx b/frontend/src/pages/admin/__tests__/settingsPageMountRace.test.tsx
new file mode 100644
index 00000000..934538de
--- /dev/null
+++ b/frontend/src/pages/admin/__tests__/settingsPageMountRace.test.tsx
@@ -0,0 +1,110 @@
+/**
+ * Settings crashed on a fresh/hard load of any non-default tab (QA J.08).
+ *
+ * The nav groups are permission-filtered, so before PermissionsContext has
+ * resolved every group filters to empty, `allItems[0]` is undefined, and the
+ * section heading's `` throws. In-app SPA navigation never
+ * hit it because the context was already warm.
+ */
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+vi.mock('react-i18next', async () => {
+ const actual = await vi.importActual('react-i18next');
+ return {
+ ...actual,
+ useTranslation: () => ({ t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k) }),
+ };
+});
+
+const flagsState = { flags: {} as Record, isLoading: false };
+vi.mock('../../../contexts/FeatureFlagsContext', () => ({
+ useFeatureFlags: () => flagsState,
+ useFeatureEnabled: () => false,
+}));
+
+const permissionsState = { hasAnyPermission: (_: string[]) => true, isLoading: false };
+vi.mock('../../../contexts/PermissionsContext', () => ({
+ usePermissions: () => permissionsState,
+}));
+
+// The settings barrel pulls in every tab; stub it down to the shell's needs.
+vi.mock('../../../features/settings', () => {
+ const Stub = () => null;
+ return {
+ useSettingsState: () => ({ isLoading: false }),
+ FeaturesTab: Stub,
+ GeneralTab: Stub,
+ EventsTab: Stub,
+ StatusTab: Stub,
+ SecurityTab: Stub,
+ ImageSecurityTab: Stub,
+ CategoriesTab: Stub,
+ AnalyticsTab: Stub,
+ ModerationTab: Stub,
+ StylingTab: Stub,
+ SEOTab: Stub,
+ ThumbnailsTab: Stub,
+ DownloadsTab: Stub,
+ ApiTokensTab: Stub,
+ WebhooksTab: Stub,
+ AccountingTab: Stub,
+ WhatsAppTab: Stub,
+ SsoTab: Stub,
+ };
+});
+
+vi.mock('../EmailConfigPage', () => ({ EmailConfigPage: () => null }));
+vi.mock('../BrandingPage', () => ({ BrandingPage: () => null }));
+vi.mock('../EventTypesPage', () => ({ EventTypesPage: () => null }));
+vi.mock('../SlideshowSettingsPage', () => ({ SlideshowSettingsPage: () => null }));
+vi.mock('../BackupManagement', () => ({ BackupManagement: () => null }));
+vi.mock('../CMSPage', () => ({ CMSPage: () => null }));
+vi.mock('../settings/SettingsBusinessProfilePage', () => ({ SettingsBusinessProfilePage: () => null }));
+vi.mock('../settings/CrmSettingsPage', () => ({ CrmSettingsPage: () => null }));
+vi.mock('../settings/ReminderTemplatesPage', () => ({ ReminderTemplatesPage: () => null }));
+vi.mock('../contracts/BlockLibraryPage', () => ({ BlockLibraryPage: () => null }));
+
+import { SettingsPage } from '../SettingsPage';
+
+function renderAt(tab: string) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+
+
+
+ );
+}
+
+describe('SettingsPage fresh-mount permission race (QA J.08)', () => {
+ beforeEach(() => {
+ permissionsState.hasAnyPermission = () => true;
+ permissionsState.isLoading = false;
+ });
+
+ it('does not crash on a deep-linked tab while permissions are still loading', () => {
+ permissionsState.isLoading = true;
+ permissionsState.hasAnyPermission = () => false;
+
+ expect(() => renderAt('webhooks')).not.toThrow();
+ expect(screen.getByText('settings.loadingSettings')).toBeInTheDocument();
+ });
+
+ it('still lands on the deep-linked tab once permissions arrive', () => {
+ renderAt('webhooks');
+
+ expect(screen.getByRole('heading', { level: 2, name: 'Webhooks' })).toBeInTheDocument();
+ });
+
+ it('does not crash when the role has no settings tab permissions at all', () => {
+ permissionsState.hasAnyPermission = () => false;
+
+ expect(() => renderAt('webhooks')).not.toThrow();
+ expect(screen.getByText('settings.title')).toBeInTheDocument();
+ });
+});
From c19e944b995dfb6c54c0cb5da5772700000613d8 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Tue, 1 Sep 2026 16:28:31 +0200
Subject: [PATCH 07/33] fix(events): guard create-event submit against
re-entrant submissions
Correction to the QA root cause: the submit Button has carried
`disabled={createMutation.isPending}` since 3424bd22, and it does disable
synchronously after the first click (validateForm's setErrors forces a
re-render that re-reads the mutation snapshot), so an ordinary double-click
could not by itself produce two POSTs.
What was actually missing is a re-entrancy guard in handleSubmit, so any
submission that never touches the button -- implicit form submission, a
programmatic requestSubmit, or two submit events dispatched in one task,
which is the likely shape of the QA repro -- still fired two mutate() calls
racing the same computed slug, one of which 500'd on events_slug_unique.
Add isSubmittingRef (matching the isMountedRef idiom already in this file),
cleared in onSettled. Test proves 2 submit events -> 1 POST.
Not done: turning the backend's raw 500 on events_slug_unique into a
graceful "event already exists" 409. That is an adminEvents.js change and a
separate concern from the client-side race.
Refs testplan REPORT.md #6 (Part 7.03).
---
frontend/src/pages/admin/CreateEventPage.tsx | 16 +-
.../createEventDoubleSubmit.test.tsx | 138 ++++++++++++++++++
2 files changed, 153 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/pages/admin/__tests__/createEventDoubleSubmit.test.tsx
diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx
index 8c39c0c6..b0a46cc3 100644
--- a/frontend/src/pages/admin/CreateEventPage.tsx
+++ b/frontend/src/pages/admin/CreateEventPage.tsx
@@ -99,6 +99,12 @@ export const CreateEventPage: React.FC = () => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const isMountedRef = useRef(true);
+ // Re-entrancy guard for the create submit. The Button's
+ // `disabled={createMutation.isPending}` covers the ordinary double-click, but
+ // not a submission that never touches the button (implicit form submission,
+ // a programmatic requestSubmit) — those raced two POSTs onto the same slug,
+ // one of which 500'd on `events_slug_unique` (QA 7.03).
+ const isSubmittingRef = useRef(false);
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
// const [showPreview, setShowPreview] = useState(false);
@@ -396,6 +402,9 @@ export const CreateEventPage: React.FC = () => {
toast.error(errorMessage);
}
},
+ onSettled: () => {
+ isSubmittingRef.current = false;
+ },
});
const validateForm = (): boolean => {
@@ -461,7 +470,11 @@ export const CreateEventPage: React.FC = () => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
-
+
+ if (isSubmittingRef.current) {
+ return;
+ }
+
if (!validateForm()) {
return;
}
@@ -518,6 +531,7 @@ export const CreateEventPage: React.FC = () => {
customer_account_ids: formData.customer_accounts.map((c) => c.id),
};
+ isSubmittingRef.current = true;
createMutation.mutate(payload);
};
diff --git a/frontend/src/pages/admin/__tests__/createEventDoubleSubmit.test.tsx b/frontend/src/pages/admin/__tests__/createEventDoubleSubmit.test.tsx
new file mode 100644
index 00000000..6043fd36
--- /dev/null
+++ b/frontend/src/pages/admin/__tests__/createEventDoubleSubmit.test.tsx
@@ -0,0 +1,138 @@
+/**
+ * "Create event" could fire two real POSTs racing the same slug — one 500'd on
+ * `events_slug_unique` (QA 7.03).
+ *
+ * The Button already carried `disabled={createMutation.isPending}`, which
+ * covers the ordinary double-click. `handleSubmit` itself had no re-entrancy
+ * guard though, so any submit that does not go through the button (implicit
+ * form submission, a programmatic `requestSubmit`) still raced a second POST.
+ */
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+vi.mock('react-i18next', async () => {
+ const actual = await vi.importActual('react-i18next');
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k),
+ i18n: { language: 'en' },
+ }),
+ };
+});
+
+vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
+
+const createEvent = vi.fn();
+vi.mock('../../../services/events.service', () => ({
+ eventsService: { createEvent: (...args: unknown[]) => createEvent(...args) },
+}));
+
+vi.mock('../../../services/categories.service', () => ({
+ categoriesService: { getCategories: vi.fn(async () => []) },
+}));
+vi.mock('../../../services/settings.service', () => ({
+ settingsService: { getAllSettings: vi.fn(async () => ({})) },
+}));
+vi.mock('../../../services/cssTemplates.service', () => ({
+ cssTemplatesService: { getEnabledTemplates: vi.fn(async () => []) },
+}));
+vi.mock('../../../services/eventTypes.service', () => ({
+ eventTypesService: { getEventTypes: vi.fn(async () => []) },
+}));
+vi.mock('../../../services/userManagement.service', () => ({
+ userManagementService: { getUsers: vi.fn(async () => []) },
+}));
+
+// Every "is this field required" flag off, so the only thing validateForm
+// needs is the event name.
+vi.mock('../../../hooks/usePublicSettings', () => ({
+ PUBLIC_SETTINGS_QUERY_KEY: ['public-settings'],
+ usePublicSettings: () => ({
+ data: {
+ event_require_customer_name: false,
+ event_require_customer_email: false,
+ event_require_admin_email: false,
+ event_require_event_date: false,
+ event_require_expiration: false,
+ event_default_require_password: false,
+ },
+ }),
+}));
+
+vi.mock('../../../contexts/AdminAuthContext', () => ({
+ useAdminAuth: () => ({ user: null }),
+}));
+
+vi.mock('../../../contexts/FeatureFlagsContext', () => ({
+ useFeatureFlags: () => ({ flags: {}, isLoading: false }),
+ useFeatureEnabled: () => false,
+}));
+
+// Heavy children not involved in the submit path.
+vi.mock('../../../components/admin', async () => {
+ const actual = await vi.importActual('../../../components/admin');
+ return {
+ ...actual,
+ ThemeCustomizerEnhanced: () => null,
+ GalleryPreview: () => null,
+ WelcomeMessageEditor: () => null,
+ FeedbackSettings: () => null,
+ };
+});
+vi.mock('../../../components/admin/CustomerAccountPicker', () => ({
+ CustomerAccountPicker: () => null,
+}));
+
+import { CreateEventPage } from '../CreateEventPage';
+
+function renderPage() {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+
+
+
+ );
+}
+
+describe('CreateEventPage double-submit guard (QA 7.03)', () => {
+ beforeEach(() => {
+ createEvent.mockReset();
+ // Never settles — keeps the mutation in flight for the whole test.
+ createEvent.mockImplementation(() => new Promise(() => {}));
+ });
+
+ it('fires exactly one POST when the form is submitted twice in a row', async () => {
+ renderPage();
+
+ fireEvent.change(screen.getByPlaceholderText('events.eventNamePlaceholder'), {
+ target: { value: 'ZZTEST double submit' },
+ });
+
+ const form = screen.getByRole('button', { name: 'events.createEvent' }).closest('form')!;
+ fireEvent.submit(form);
+ fireEvent.submit(form);
+
+ await waitFor(() => expect(createEvent).toHaveBeenCalled());
+ expect(createEvent).toHaveBeenCalledTimes(1);
+ });
+
+ it('disables the submit button while the request is in flight', async () => {
+ renderPage();
+
+ fireEvent.change(screen.getByPlaceholderText('events.eventNamePlaceholder'), {
+ target: { value: 'ZZTEST in flight' },
+ });
+
+ const submit = screen.getByRole('button', { name: 'events.createEvent' }) as HTMLButtonElement;
+ fireEvent.click(submit);
+
+ expect(submit).toBeDisabled();
+ await waitFor(() => expect(createEvent).toHaveBeenCalledTimes(1));
+ });
+});
From 31ffbc8ae40f3b913649ac11a1f5f5b1a14a130f Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Tue, 1 Sep 2026 16:28:31 +0200
Subject: [PATCH 08/33] fix(users): give the cancel-invitation dialog a
distinct confirm label
The cancelInvitation dialog type fell through to the generic
t('userManagement.cancel'), colliding with ConfirmDialog's own dismiss
button -- two buttons both reading "Cancel", where clicking the wrong one
does the opposite of what the user intends.
Reuse the existing userManagement.cancelInvitation key: "Cancel Invitation"
vs "Cancel" (EN), "Einladung abbrechen" vs "Abbrechen" (DE). No new key.
Refs testplan REPORT.md #19 (Part 3, I.04).
---
frontend/src/pages/admin/UserManagementPage.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/frontend/src/pages/admin/UserManagementPage.tsx b/frontend/src/pages/admin/UserManagementPage.tsx
index a1a6e5f3..c05a62f3 100644
--- a/frontend/src/pages/admin/UserManagementPage.tsx
+++ b/frontend/src/pages/admin/UserManagementPage.tsx
@@ -1023,7 +1023,9 @@ export const UserManagementPage: React.FC = () => {
confirmDialog.type === 'deactivate' ? t('userManagement.deactivate')
: confirmDialog.type === 'activate' ? t('userManagement.activate', 'Reactivate')
: confirmDialog.type === 'delete' ? t('userManagement.delete', 'Delete permanently')
- : t('userManagement.cancel')
+ // Not the generic `cancel` — that collides with ConfirmDialog's own
+ // dismiss button, giving the dialog two "Cancel" buttons (QA I.04).
+ : t('userManagement.cancelInvitation')
}
isLoading={
confirmDialog.type === 'deactivate' ? deactivateUserMutation.isPending
From c5c5a6b0c87ad7a3797e7f8ab695c5f64abfacf8 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Tue, 1 Sep 2026 16:29:03 +0200
Subject: [PATCH 09/33] fix(webhooks): write delivery timestamps as ISO strings
Applies the repo's documented Jest+SQLite guidance (CLAUDE.md) to the webhook
delivery path, which was the last one still passing raw Date objects into
knex writes. Under jest those store as the literal string "[object Object]",
so next_retry_at came back NaN and the retry/backoff test could not assert on
it. Production (PG, and SQLite outside jest) was unaffected.
Convert the timestamp writes -- and the `next_retry_at <=` due comparison,
which has to stay type-consistent with them -- to .toISOString(), matching
the existing precedent in downloadJobService.js.
Refs testplan REPORT.md #22 (Part 1.2.01).
---
.../integration/webhookDelivery.test.js | 12 ++++----
backend/src/services/webhookDeliveryWorker.js | 28 +++++++++----------
backend/src/services/webhookService.js | 8 +++---
3 files changed, 24 insertions(+), 24 deletions(-)
diff --git a/backend/__tests__/integration/webhookDelivery.test.js b/backend/__tests__/integration/webhookDelivery.test.js
index 14ed1ab5..35de5a18 100644
--- a/backend/__tests__/integration/webhookDelivery.test.js
+++ b/backend/__tests__/integration/webhookDelivery.test.js
@@ -146,8 +146,8 @@ describe('webhook delivery worker (#327)', () => {
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
attempt_count: 4,
status: 'pending',
- next_retry_at: new Date(),
- created_at: new Date(),
+ next_retry_at: new Date().toISOString(),
+ created_at: new Date().toISOString(),
});
await __test.tick();
@@ -190,8 +190,8 @@ describe('webhook delivery worker (#327)', () => {
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
attempt_count: 0,
status: 'pending',
- next_retry_at: new Date(),
- created_at: new Date(),
+ next_retry_at: new Date().toISOString(),
+ created_at: new Date().toISOString(),
});
await __test.tick();
@@ -214,8 +214,8 @@ describe('webhook delivery worker (#327)', () => {
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
attempt_count: 0,
status: 'pending',
- next_retry_at: new Date(),
- created_at: new Date(),
+ next_retry_at: new Date().toISOString(),
+ created_at: new Date().toISOString(),
});
await __test.tick();
diff --git a/backend/src/services/webhookDeliveryWorker.js b/backend/src/services/webhookDeliveryWorker.js
index a619e05b..2bb91cb4 100644
--- a/backend/src/services/webhookDeliveryWorker.js
+++ b/backend/src/services/webhookDeliveryWorker.js
@@ -51,7 +51,7 @@ async function fetchPending(limit) {
const excludeIds = Array.from(inFlight);
let q = db('webhook_deliveries')
.where('status', 'pending')
- .where('next_retry_at', '<=', new Date())
+ .where('next_retry_at', '<=', new Date().toISOString())
.orderBy('next_retry_at', 'asc')
.limit(limit);
if (excludeIds.length > 0) {
@@ -71,7 +71,7 @@ async function deliverOne(row) {
.update({
status: 'failed',
last_error: 'webhook subscription no longer exists',
- completed_at: new Date(),
+ completed_at: new Date().toISOString(),
attempt_count: row.attempt_count + 1,
});
return;
@@ -85,7 +85,7 @@ async function deliverOne(row) {
.update({
status: 'failed',
last_error: 'webhook is disabled',
- completed_at: new Date(),
+ completed_at: new Date().toISOString(),
attempt_count: row.attempt_count + 1,
});
return;
@@ -172,10 +172,10 @@ async function deliverOne(row) {
response_body: truncate(stringifyBody(response.data), RESPONSE_TRUNCATE_BYTES),
latency_ms: latency,
attempt_count: newAttempt,
- completed_at: new Date(),
+ completed_at: new Date().toISOString(),
next_retry_at: null,
});
- await db('webhooks').where({ id: webhook.id }).update({ last_success_at: new Date() });
+ await db('webhooks').where({ id: webhook.id }).update({ last_success_at: new Date().toISOString() });
return;
}
@@ -194,10 +194,10 @@ async function deliverOne(row) {
last_error: errorMsg,
latency_ms: latency,
attempt_count: newAttempt,
- completed_at: new Date(),
+ completed_at: new Date().toISOString(),
next_retry_at: null,
});
- await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date() });
+ await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date().toISOString() });
return;
}
@@ -211,9 +211,9 @@ async function deliverOne(row) {
last_error: errorMsg,
latency_ms: latency,
attempt_count: newAttempt,
- next_retry_at: new Date(Date.now() + backoff),
+ next_retry_at: new Date(Date.now() + backoff).toISOString(),
});
- await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date() });
+ await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date().toISOString() });
}
async function markFailedFinal(row, reason) {
@@ -223,10 +223,10 @@ async function markFailedFinal(row, reason) {
status: 'failed',
last_error: reason,
attempt_count: row.attempt_count + 1,
- completed_at: new Date(),
+ completed_at: new Date().toISOString(),
next_retry_at: null,
});
- await db('webhooks').where({ id: row.webhook_id }).update({ last_failure_at: new Date() });
+ await db('webhooks').where({ id: row.webhook_id }).update({ last_failure_at: new Date().toISOString() });
}
// Schedule the normal retry/backoff for a transient failure that must not
@@ -242,7 +242,7 @@ async function scheduleTransientRetry(row, webhook, errorMsg) {
status: 'failed',
last_error: errorMsg,
attempt_count: newAttempt,
- completed_at: new Date(),
+ completed_at: new Date().toISOString(),
next_retry_at: null,
});
} else {
@@ -253,10 +253,10 @@ async function scheduleTransientRetry(row, webhook, errorMsg) {
status: 'pending',
last_error: errorMsg,
attempt_count: newAttempt,
- next_retry_at: new Date(Date.now() + backoff),
+ next_retry_at: new Date(Date.now() + backoff).toISOString(),
});
}
- await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date() });
+ await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date().toISOString() });
}
function stringifyBody(data) {
diff --git a/backend/src/services/webhookService.js b/backend/src/services/webhookService.js
index 9f450d66..80e58c9d 100644
--- a/backend/src/services/webhookService.js
+++ b/backend/src/services/webhookService.js
@@ -190,8 +190,8 @@ async function fire(eventType, data) {
payload: JSON.stringify(envelope),
attempt_count: 0,
status: 'pending',
- next_retry_at: now,
- created_at: now,
+ next_retry_at: now.toISOString(),
+ created_at: now.toISOString(),
});
}
@@ -232,8 +232,8 @@ async function enqueueForWebhook(webhookId, eventType, data) {
payload: JSON.stringify(envelope),
attempt_count: 0,
status: 'pending',
- next_retry_at: now,
- created_at: now,
+ next_retry_at: now.toISOString(),
+ created_at: now.toISOString(),
});
return { enqueued: true, webhookId: w.id, deliveryId: deliveryUuid };
} catch (err) {
From 1d84c738d8e7df46246b0f896fceee36afe20813 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Tue, 1 Sep 2026 16:29:03 +0200
Subject: [PATCH 10/33] test: repair four stale backend suites
All four asserted contracts the product has since moved past. No genuine
product bugs behind any of them; assertions were tightened, not loosened.
adminAuth (3 tests): never mounted errorHandler, so ConflictError/
ValidationError arrived as empty Express defaults. The route also checks
username before email, so the "email conflict" fixture was hitting the
username branch. Mount the handler, fix the fixture, match the real response
shapes.
backupService.enhanced (12 tests): three stacked drifts -- the db mock had no
.returning(), so every runBackup threw at the insert; ensureDatabaseDumpForBackup
now lazily requires ./databaseBackup inside the run, which fails under
mock-fs; and the rsync path moved from exec(shell string) to
spawnAsync('rsync', args) with an isHostAllowed SSRF preflight. Also updates
getBackupStatus to its current shape (frontend aliases, nextScheduledRun null
when no schedule is enabled, #871).
adminSettings.logo: POST /logo gained requirePermission('settings.edit');
the hand-rolled db mock returns a bare Promise from select(), so the
permission lookup threw a TypeError into a 500. Mock the permissions
middleware alongside the already-mocked auth.
crmMintPaths (2 tests): macOS-only. The expected prefix was realpath'd while
the services persist under the raw STORAGE_PATH -- identical on Linux CI
(/var vs /private/var only diverges on macOS), which is why it passed there.
The comment justifying the realpath referenced process.cwd() behaviour the
services no longer have.
Refs testplan REPORT.md #22 (Part 1.2.01).
---
backend/__tests__/adminSettings.logo.test.js | 5 +
.../integration/crmMintPaths.test.js | 11 +-
.../services/backupService.enhanced.test.js | 155 ++++++++++++------
.../src/routes/__tests__/adminAuth.test.js | 24 ++-
4 files changed, 139 insertions(+), 56 deletions(-)
diff --git a/backend/__tests__/adminSettings.logo.test.js b/backend/__tests__/adminSettings.logo.test.js
index 16eb8911..0ff1c17d 100644
--- a/backend/__tests__/adminSettings.logo.test.js
+++ b/backend/__tests__/adminSettings.logo.test.js
@@ -110,6 +110,11 @@ describe('Admin settings logo upload flow', () => {
}
}));
+ jest.doMock('../src/middleware/permissions', () => ({
+ requirePermission: () => (req, res, next) => next(),
+ userHasAnyPermission: jest.fn().mockResolvedValue(true)
+ }));
+
jest.doMock('../src/services/publicSiteService', () => ({
clearPublicSiteCache: jest.fn(),
getDefaultPublicSitePayload: jest.fn(),
diff --git a/backend/__tests__/integration/crmMintPaths.test.js b/backend/__tests__/integration/crmMintPaths.test.js
index 74536a9c..92e19d6a 100644
--- a/backend/__tests__/integration/crmMintPaths.test.js
+++ b/backend/__tests__/integration/crmMintPaths.test.js
@@ -148,10 +148,15 @@ async function seedCustomerSignedContract() {
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
// Business-doc PDFs (quotes/invoices/contracts) persist under
- // `process.cwd()/storage/business-docs/...` — chdir into the temp dir
- // so every test artifact lands isolated and gets cleaned up.
+ // `getStoragePath()/business-docs/...`, and safePath also allows a
+ // `process.cwd()/storage/business-docs/...` root — chdir into the temp
+ // dir so every test artifact lands isolated and gets cleaned up.
process.chdir(tmpDir);
- storageRoot = path.join(fs.realpathSync(tmpDir), 'storage', 'business-docs');
+ // Mirror what the services store: the raw STORAGE_PATH bootCrmDb
+ // exported, NOT a symlink-resolved variant. On macOS os.tmpdir() is
+ // /var/... while realpath is /private/var/..., so canonicalizing here
+ // would make every stored path fail the prefix check.
+ storageRoot = path.join(process.env.STORAGE_PATH, 'business-docs');
// Fail-fast on the pre-existing logActivity-inside-transaction
// deadlock: createContract and createStorno call logActivity() from
diff --git a/backend/__tests__/services/backupService.enhanced.test.js b/backend/__tests__/services/backupService.enhanced.test.js
index e2349727..a300b9d2 100644
--- a/backend/__tests__/services/backupService.enhanced.test.js
+++ b/backend/__tests__/services/backupService.enhanced.test.js
@@ -11,8 +11,27 @@ jest.mock('../../src/services/emailProcessor');
jest.mock('node-cron');
jest.mock('../../src/services/backupManifest');
jest.mock('../../src/services/storage/s3Storage');
+// runBackup lazily requires this from inside the run — resolve+register it
+// here so the require doesn't hit the (mock-fs'd) filesystem mid-backup.
+jest.mock('../../src/services/databaseBackup', () => ({
+ databaseBackupService: {
+ backup: jest.fn()
+ }
+}));
+// Same deal for the rsync path's lazy requires.
+jest.mock('../../src/utils/safeExec', () => ({
+ spawnAsync: jest.fn(),
+ spawnToFile: jest.fn(),
+ spawnFromFile: jest.fn()
+}));
+jest.mock('../../src/utils/networkValidation', () => ({
+ isHostAllowed: jest.fn().mockResolvedValue(true)
+}));
const backupService = require('../../src/services/backupService');
+const { databaseBackupService } = require('../../src/services/databaseBackup');
+const { spawnAsync } = require('../../src/utils/safeExec');
+const { isHostAllowed } = require('../../src/utils/networkValidation');
const { db } = require('../../src/database/db');
const logger = require('../../src/utils/logger');
const { queueEmail } = require('../../src/services/emailProcessor');
@@ -20,6 +39,20 @@ const cron = require('node-cron');
const backupManifest = require('../../src/services/backupManifest');
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
+// `runBackup` opens the run row with `db('backup_runs').insert(...).returning('id')`,
+// so the insert mock has to be awaitable AND carry a `.returning()`.
+const insertResult = (value) => {
+ const thenable = Promise.resolve(value);
+ thenable.returning = jest.fn().mockResolvedValue(value);
+ return thenable;
+};
+
+// Every runBackup goes through ensureDatabaseDumpForBackup, which stats the
+// DB dump on disk and refuses to continue without it — seed it into every
+// mock-fs tree.
+const DB_DUMP_PATH = '/backup/db-dump.sql';
+const mockStorage = (tree) => mockFs({ [DB_DUMP_PATH]: Buffer.from('database dump'), ...tree });
+
describe('Enhanced Backup Service Tests', () => {
let mockDb;
let mockS3Client;
@@ -36,7 +69,7 @@ describe('Enhanced Backup Service Tests', () => {
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
first: jest.fn(),
- insert: jest.fn(),
+ insert: jest.fn(() => insertResult([1])),
update: jest.fn(),
delete: jest.fn()
};
@@ -75,6 +108,19 @@ describe('Enhanced Backup Service Tests', () => {
logger.error = jest.fn();
logger.warn = jest.fn();
logger.debug = jest.fn();
+
+ // The inline DB dump and its on-disk verification run on every backup and
+ // throw when no dump is available — give both a passing default so each
+ // test can focus on the destination path it actually covers.
+ databaseBackupService.backup.mockResolvedValue({ path: DB_DUMP_PATH, size: 13 });
+ isHostAllowed.mockResolvedValue(true);
+ jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
+ type: 'sqlite',
+ backupFile: DB_DUMP_PATH,
+ size: 13,
+ checksum: 'abc123',
+ hasChanged: false
+ });
});
afterEach(() => {
@@ -132,7 +178,7 @@ describe('Enhanced Backup Service Tests', () => {
describe('S3 Backup Functionality', () => {
beforeEach(() => {
// Mock file system
- mockFs({
+ mockStorage({
'/storage/events/active/event1': {
'photo1.jpg': Buffer.from('photo1 content'),
'photo2.jpg': Buffer.from('photo2 content')
@@ -165,12 +211,12 @@ describe('Enhanced Backup Service Tests', () => {
mockDb.select.mockResolvedValue([]);
mockDb.where.mockReturnThis();
mockDb.first.mockResolvedValue(null);
- mockDb.insert.mockResolvedValue([1]);
+ mockDb.insert.mockReturnValue(insertResult([1]));
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
type: 'sqlite',
- backupFile: null,
+ backupFile: DB_DUMP_PATH,
hasChanged: true
});
@@ -202,7 +248,7 @@ describe('Enhanced Backup Service Tests', () => {
};
mockDb.select.mockResolvedValue([]);
- mockDb.insert.mockResolvedValue([1]);
+ mockDb.insert.mockReturnValue(insertResult([1]));
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
@@ -240,7 +286,7 @@ describe('Enhanced Backup Service Tests', () => {
});
mockDb.select.mockResolvedValue([]);
- mockDb.insert.mockResolvedValue([1]);
+ mockDb.insert.mockReturnValue(insertResult([1]));
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
@@ -265,7 +311,7 @@ describe('Enhanced Backup Service Tests', () => {
};
mockDb.select.mockResolvedValue([]);
- mockDb.insert.mockResolvedValue([1]);
+ mockDb.insert.mockReturnValue(insertResult([1]));
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
@@ -277,7 +323,7 @@ describe('Enhanced Backup Service Tests', () => {
});
// Mock database backup file
- mockFs({
+ mockStorage({
'/storage/events/active': {},
'/backup/db-backup.sql': Buffer.from('database backup content')
});
@@ -301,7 +347,7 @@ describe('Enhanced Backup Service Tests', () => {
};
mockDb.select.mockResolvedValue([]);
- mockDb.insert.mockResolvedValue([1]);
+ mockDb.insert.mockReturnValue(insertResult([1]));
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
@@ -326,12 +372,12 @@ describe('Enhanced Backup Service Tests', () => {
};
mockDb.select.mockResolvedValue([]);
- mockDb.insert.mockResolvedValue([1]);
+ mockDb.insert.mockReturnValue(insertResult([1]));
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
- mockFs({
+ mockStorage({
'/storage/events/active/event1': {
'photo1.jpg': Buffer.from('photo1 content')
},
@@ -365,7 +411,7 @@ describe('Enhanced Backup Service Tests', () => {
};
mockDb.select.mockResolvedValue([]);
- mockDb.insert.mockResolvedValue([2]);
+ mockDb.insert.mockReturnValue(insertResult([2]));
mockDb.first.mockImplementation(() => Promise.resolve(lastBackup));
mockDb.orderBy.mockReturnThis();
mockDb.where.mockReturnThis();
@@ -373,7 +419,7 @@ describe('Enhanced Backup Service Tests', () => {
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
- mockFs({
+ mockStorage({
'/storage/events/active': {},
'/backup': {}
});
@@ -395,7 +441,7 @@ describe('Enhanced Backup Service Tests', () => {
};
mockDb.select.mockResolvedValue([]);
- mockDb.insert.mockResolvedValue([1]);
+ mockDb.insert.mockReturnValue(insertResult([1]));
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
@@ -406,7 +452,7 @@ describe('Enhanced Backup Service Tests', () => {
};
backupManifest.generateManifest.mockResolvedValue(manifest);
- mockFs({
+ mockStorage({
'/storage/events/active': {},
'/storage/temp': {}
});
@@ -431,12 +477,12 @@ describe('Enhanced Backup Service Tests', () => {
};
mockDb.select.mockResolvedValue([]);
- mockDb.insert.mockResolvedValue([1]);
+ mockDb.insert.mockReturnValue(insertResult([1]));
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
- mockFs({
+ mockStorage({
'/storage/events/active/event1': {
'photo1.jpg': Buffer.from('photo1 content')
},
@@ -461,28 +507,27 @@ describe('Enhanced Backup Service Tests', () => {
};
mockDb.select.mockResolvedValue([]);
- mockDb.insert.mockResolvedValue([1]);
+ mockDb.insert.mockReturnValue(insertResult([1]));
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
-
- // Mock exec for rsync
- const { exec } = require('child_process');
- const mockExec = jest.fn((cmd, callback) => {
- callback(null, { stdout: 'Number of files transferred: 1\nTotal file size: 1024 bytes' });
+
+ // rsync is spawned argv-style (no shell) — assert that shape, not the
+ // legacy `exec('rsync ...')` string.
+ spawnAsync.mockResolvedValue({
+ stdout: 'Number of files transferred: 1\nTotal file size: 1024 bytes'
});
- exec.mockImplementation(mockExec);
-
- mockFs({
+
+ mockStorage({
'/storage/events/active': {}
});
-
+
await backupService.runBackup();
-
- expect(mockExec).toHaveBeenCalledWith(
- expect.stringContaining('rsync'),
- expect.any(Function)
- );
+
+ expect(spawnAsync).toHaveBeenCalledWith('rsync', expect.any(Array));
+ const [, rsyncArgs] = spawnAsync.mock.calls[0];
+ expect(rsyncArgs).toContain('-avz');
+ expect(rsyncArgs[rsyncArgs.length - 1]).toBe('backup@backup.example.com:/remote/backup');
});
});
@@ -497,7 +542,7 @@ describe('Enhanced Backup Service Tests', () => {
};
mockDb.select.mockResolvedValue([]);
- mockDb.insert.mockResolvedValue([1]);
+ mockDb.insert.mockReturnValue(insertResult([1]));
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
@@ -514,7 +559,7 @@ describe('Enhanced Backup Service Tests', () => {
return originalCreateReadStream(path);
});
- mockFs({
+ mockStorage({
'/storage/events/active': {
'error.jpg': Buffer.from('content'),
'good.jpg': Buffer.from('content')
@@ -546,7 +591,7 @@ describe('Enhanced Backup Service Tests', () => {
];
mockDb.select.mockResolvedValue([]);
- mockDb.insert.mockResolvedValue([1]);
+ mockDb.insert.mockReturnValue(insertResult([1]));
mockDb.where.mockReturnThis();
jest.spyOn(backupService, 'getBackupConfig')
@@ -555,7 +600,13 @@ describe('Enhanced Backup Service Tests', () => {
// Force an error
jest.spyOn(backupService, 'getFilesToBackup').mockRejectedValue(new Error('Storage error'));
-
+
+ // The DB-dump verification runs first and would throw its own error —
+ // give it a tree so 'Storage error' is what actually surfaces.
+ mockStorage({
+ '/storage/events/active': {}
+ });
+
// Mock admin users query
db.mockImplementation((table) => {
if (table === 'admin_users') {
@@ -588,7 +639,7 @@ describe('Enhanced Backup Service Tests', () => {
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
- mockFs({
+ mockStorage({
'/storage/events/active': {},
'/backup': {}
});
@@ -675,20 +726,32 @@ describe('Enhanced Backup Service Tests', () => {
];
mockDb.limit.mockResolvedValue(recentRuns);
-
+ // getBackupStatus also reads the backup config to compute the next run;
+ // an unscheduled/disabled backup legitimately yields null (#871).
+ mockDb.select.mockResolvedValue([
+ { setting_key: 'backup_enabled', setting_value: 'true' },
+ { setting_key: 'backup_schedule', setting_value: '"daily"' }
+ ]);
+
backupManifest.validateManifest.mockImplementation(() => true);
-
+
const status = await backupService.getBackupStatus();
-
+
+ // Runs are returned with a `created_at` alias for the frontend.
+ const run = { ...recentRuns[0], created_at: recentRuns[0].started_at };
+
expect(status).toEqual({
isRunning: false,
isHealthy: true,
- lastRun: expect.objectContaining({
- ...recentRuns[0],
- manifestValid: true
- }),
- recentRuns: recentRuns,
- nextScheduledRun: expect.any(String)
+ lastRun: { ...run, manifestValid: true },
+ lastBackup: { ...run, manifestValid: true },
+ lastSuccessfulBackup: run,
+ zombieRuns: [],
+ recentRuns: [run],
+ recentBackups: [run],
+ totalBackups: 1,
+ nextScheduledRun: expect.any(String),
+ nextBackup: expect.any(String)
});
});
diff --git a/backend/src/routes/__tests__/adminAuth.test.js b/backend/src/routes/__tests__/adminAuth.test.js
index 934fd464..8ce68347 100644
--- a/backend/src/routes/__tests__/adminAuth.test.js
+++ b/backend/src/routes/__tests__/adminAuth.test.js
@@ -36,11 +36,13 @@ jest.mock('../../middleware/auth', () => ({
const { db, logActivity } = require('../../database/db');
const adminAuthRouter = require('../adminAuth');
+const { errorHandler } = require('../../middleware/errorHandler');
describe('adminAuth profile updates', () => {
const app = express();
app.use(express.json());
app.use('/auth/admin', adminAuthRouter);
+ app.use(errorHandler);
beforeEach(() => {
jest.clearAllMocks();
@@ -55,8 +57,8 @@ describe('adminAuth profile updates', () => {
};
db.__setImplementations(
- buildChain({ firstResult: null }), // email check
buildChain({ firstResult: null }), // username check
+ buildChain({ firstResult: null }), // email check
buildChain({ updateResult: 1 }), // update
buildChain({ firstResult: updatedUser }), // fetch updated user
);
@@ -66,18 +68,22 @@ describe('adminAuth profile updates', () => {
.send({ username: updatedUser.username, email: updatedUser.email })
.expect(200);
- expect(response.body).toEqual({ user: updatedUser });
+ expect(response.body).toEqual({
+ message: 'Admin profile updated successfully',
+ user: updatedUser
+ });
expect(logActivity).toHaveBeenCalledWith(
'admin_profile_updated',
- { admin_id: 1, updated_fields: ['username', 'email'] },
+ { username: updatedUser.username, email: updatedUser.email },
null,
- { type: 'admin', id: 1, name: updatedUser.username }
+ { type: 'admin', id: 1, name: 'admin' }
);
});
it('rejects email conflicts', async () => {
db.__setImplementations(
- buildChain({ firstResult: { id: 2 } })
+ buildChain({ firstResult: null }), // username check
+ buildChain({ firstResult: { id: 2 } }), // email check
);
const response = await request(app)
@@ -85,7 +91,11 @@ describe('adminAuth profile updates', () => {
.send({ username: 'newadmin', email: 'taken@example.com' })
.expect(409);
- expect(response.body).toEqual({ error: 'Email is already in use by another admin' });
+ expect(response.body).toEqual({
+ error: 'Email address is already in use',
+ code: 'CONFLICT',
+ field: 'email'
+ });
});
it('validates input', async () => {
@@ -94,6 +104,6 @@ describe('adminAuth profile updates', () => {
.send({ username: '', email: 'not-an-email' })
.expect(400);
- expect(response.body.errors).toBeDefined();
+ expect(response.body.details).toBeDefined();
});
});
From 3790156fc9d613b2f1769f7ffd1181fb4db694b1 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Tue, 1 Sep 2026 16:29:33 +0200
Subject: [PATCH 11/33] fix(accounting): let "bill to a customer" work with the
portal off
CustomerAccountPicker returns null when customerPortal is off. That is right
for its original use -- the event form assigns portal logins that bypass the
gallery password -- but the Accounting flows reuse it as-is, so their required
"Client" field rendered a bare label with no input and the submit button could
never enable, with no explanation. Accounting-on + CRM-off is a valid,
UI-supported flag combination.
Took option (a): the bill-to-customer path does not depend on the portal.
POST /admin/expenses/:id/invoice is gated by requireExpenses + accounting.manage
only, and /admin/customers{,/search} are permission-gated rather than
flag-gated -- POST /admin/customers exists precisely to create passive,
portal-less customers "to attach a quote / invoice / gallery to". The
un-gated CustomerPicker used by the quote/bill/contract editors is the
precedent. (The comment claiming search 410s with the flag off was stale.)
Add portalAssignment (default true) so the gate and the portal-specific
label/help text apply only in event-assignment mode; the accounting call
sites render their own label. Event-form behaviour is unchanged.
Also fixes AccountingInboxPage's TriageModal, which has the identical
label-only failure on the rebill disposition from the same root cause --
outside the reported surface, but leaving it would half-fix the bug.
Refs testplan REPORT.md #7 (Part 8, S10).
---
.../admin/CustomerAccountPicker.tsx | 42 +++++++++----
.../customerAccountPickerPortalGate.test.tsx | 62 +++++++++++++++++++
.../admin/accounting/AccountingInboxPage.tsx | 6 +-
.../admin/accounting/ExpensesLedgerPage.tsx | 6 +-
4 files changed, 103 insertions(+), 13 deletions(-)
create mode 100644 frontend/src/components/admin/__tests__/customerAccountPickerPortalGate.test.tsx
diff --git a/frontend/src/components/admin/CustomerAccountPicker.tsx b/frontend/src/components/admin/CustomerAccountPicker.tsx
index 9d2f710a..eda64a8e 100644
--- a/frontend/src/components/admin/CustomerAccountPicker.tsx
+++ b/frontend/src/components/admin/CustomerAccountPicker.tsx
@@ -24,6 +24,21 @@ interface Props {
value: SelectedCustomer[];
onChange: (next: SelectedCustomer[]) => void;
disabled?: boolean;
+ /**
+ * Event-form mode (default): this picker IS part of the customer-portal
+ * feature — it assigns portal logins to a gallery, so it hides itself
+ * when `customerPortal` is off and explains the password bypass.
+ *
+ * Pass false where the picker only needs to identify an existing
+ * customer record (Accounting → "bill this to a client"). Those
+ * surfaces have their own gates (`accounting` / `expenses` /
+ * `incomingInvoices`) and their data path never touches the portal:
+ * /admin/customers{,/search} are permission-gated, not flag-gated, and
+ * POST /admin/customers explicitly creates passive, portal-less
+ * customers "to attach a quote / invoice / gallery to". Callers in this
+ * mode render their own field label.
+ */
+ portalAssignment?: boolean;
}
const labelFor = (c: { email: string; displayName?: string | null; companyName?: string | null }) => {
@@ -31,7 +46,7 @@ const labelFor = (c: { email: string; displayName?: string | null; companyName?:
return display ? `${display} · ${c.email}` : c.email;
};
-export const CustomerAccountPicker: React.FC = ({ value, onChange, disabled }) => {
+export const CustomerAccountPicker: React.FC = ({ value, onChange, disabled, portalAssignment = true }) => {
const { t } = useTranslation();
// Rules of Hooks: the feature-flag gate (early-return) is moved to
// the very end of this hook list (see end of function). The previous
@@ -111,19 +126,24 @@ export const CustomerAccountPicker: React.FC = ({ value, onChange, disabl
);
// Feature-flag gate (deliberately placed AFTER all hooks — see the
- // long comment at the top of this component for why). When the
- // customerPortal flag is off the backend returns 410 on
- // /admin/customers/search anyway, but hiding the UI here keeps the
- // event form clean and removes the dangling "Customer accounts"
- // label that would otherwise appear above an empty placeholder.
- if (!customerPortalEnabled) return null;
+ // long comment at the top of this component for why). Only applies to
+ // the event-assignment mode: hiding the UI there keeps the event form
+ // clean and removes the dangling "Customer accounts" label that would
+ // otherwise appear above an empty placeholder. Non-portal call sites
+ // must NOT be gated — their required customer field would render as a
+ // lone label with no input at all (QA S10).
+ if (portalAssignment && !customerPortalEnabled) return null;
return (
-
-
{helpText}
+ {portalAssignment && (
+ <>
+
+
{helpText}
+ >
+ )}
{/* Selected chips */}
{value.length > 0 && (
diff --git a/frontend/src/components/admin/__tests__/customerAccountPickerPortalGate.test.tsx b/frontend/src/components/admin/__tests__/customerAccountPickerPortalGate.test.tsx
new file mode 100644
index 00000000..e78efcc2
--- /dev/null
+++ b/frontend/src/components/admin/__tests__/customerAccountPickerPortalGate.test.tsx
@@ -0,0 +1,62 @@
+/**
+ * The Accounting "bill this to a client" modals reuse CustomerAccountPicker,
+ * which used to hide itself whenever `customerPortal` was off — the default.
+ * The required field then rendered as a lone label with no input and the
+ * submit button could never enable (QA S10).
+ *
+ * Accounting/customerPortal is a supported flag combination: /admin/customers
+ * and /admin/customers/search are permission-gated, not flag-gated, and
+ * POST /admin/customers creates passive (portal-less) customers on purpose.
+ */
+import React from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+vi.mock('react-i18next', async () => {
+ const actual = await vi.importActual('react-i18next');
+ return {
+ ...actual,
+ useTranslation: () => ({ t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k) }),
+ };
+});
+
+let portalEnabled = false;
+vi.mock('../../../contexts/FeatureFlagsContext', () => ({
+ useFeatureEnabled: () => portalEnabled,
+}));
+
+vi.mock('../../../services/customerAdmin.service', () => ({
+ customerAdminService: { search: vi.fn().mockResolvedValue([]) },
+}));
+
+import { CustomerAccountPicker } from '../CustomerAccountPicker';
+
+const SEARCH_PLACEHOLDER = 'Search by email, name, or company';
+const PORTAL_LABEL = 'Customer accounts';
+
+describe('CustomerAccountPicker portal gate (QA S10)', () => {
+ it('renders a usable search input with customerPortal off when portalAssignment=false', () => {
+ portalEnabled = false;
+ render( {}} />);
+
+ expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument();
+ // The caller renders its own field label ("Client *"), so the portal
+ // label + gallery-password help text stay out of the way.
+ expect(screen.queryByText(PORTAL_LABEL)).not.toBeInTheDocument();
+ });
+
+ it('still hides itself entirely on the event form when customerPortal is off', () => {
+ portalEnabled = false;
+ const { container } = render( {}} />);
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('keeps the portal label + help text on the event form when customerPortal is on', () => {
+ portalEnabled = true;
+ render( {}} />);
+
+ expect(screen.getByText(PORTAL_LABEL)).toBeInTheDocument();
+ expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx
index 3b8f1b8c..0db463c2 100644
--- a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx
+++ b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx
@@ -295,7 +295,11 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
{BOOKING_DISPOSITIONS.includes(disposition) && (
- setCustomer(next.slice(-1))} />
+ {/* portalAssignment={false} — same reason as the expenses
+ ledger: this is an `incomingInvoices` flow, not a
+ customer-portal one, and the rebill disposition's
+ required field would otherwise render label-only. */}
+ setCustomer(next.slice(-1))} />
{disposition === 'durchlaufend' &&
{t('accounting.inbox.field.passthroughCustomerHint', 'Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event.')}
}
{/* Markup is a re-bill concept only. A pass-through is invoiced
diff --git a/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx b/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx
index 67e6021d..660811f1 100644
--- a/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx
+++ b/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx
@@ -209,7 +209,11 @@ const InvoiceExpenseModal: React.FC<{ expense: Expense; onClose: () => void; onD
{t('accounting.ledger.invoiceHint', 'This creates a billable line on the client’s next scheduled invoice and locks the expense from further edits.')}
- setCustomer(next.slice(-1))} />
+ {/* portalAssignment={false}: re-billing an expense is an Accounting
+ flow gated by `expenses`, not by the customer portal — without
+ this the required field renders a bare label and the submit
+ button can never enable (QA S10). */}
+ setCustomer(next.slice(-1))} />