Merge reviewed configuration-transfer safety candidate for CI repair

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-25 08:00:57 +00:00
11 changed files with 719 additions and 166 deletions
@@ -1864,6 +1864,9 @@ removal/failure retention and containing publication remain separate evidence.
142. `frontend-modern/src/components/Settings/__tests__/settingsLocalization.test.ts`
143. `frontend-modern/src/i18n/__tests__/i18n.test.ts`
- `frontend-modern/src/components/Settings/BackupTransferDialogs.tsx`
- `frontend-modern/src/components/Settings/useBackupTransferFlow.ts`
## Shared Boundaries
### Transport-independent operator rows
@@ -9997,3 +10000,32 @@ transcript block moved to `frontend-modern/src/components/patrol/` with its
`CopyValueButton` composition intact. The External agents settings page lives
at `/settings/pulse-intelligence/agents`; the old `/assistant` path redirects
there.
### Configuration transfers retain their confirmed workspace
Recovery's export/import dialogs capture and display the effective workspace ID
when opened, including explicit `default` scope. Their production flow freezes
inputs before asynchronous file reads, binds both POSTs through the API client's
`expectedOrgID` and `retry: false`, and rejects a changed workspace before dispatch
(including the client's post-CSRF recheck). An invalid organisation must never
fall back to default, and a CSRF refusal must not replay a transfer. Only one
transfer can be pending; controls and dismissal remain disabled while waiting.
Token entry saves a credential without submitting either transfer. The operator
must review the target and submit explicitly, including after a server refusal.
A failed/unknown import response is not proof that nothing applied: persistent
in-dialog guidance requires checking the destination before another attempt.
Exports discard late data after a workspace change; successful imports identify
the original workspace and never reload a different one. Leaving Settings aborts
the client's wait and suppresses late downloads, notices and reload timers; it
does not promise to undo a server-side import or prevent transport-level replay.
A non-retrying application fetch is not server-side single-application protection.
Existing archive formats,
authorisation, scope and encrypted payloads remain unchanged.
`useBackupTransferFlow.test.ts` runs the real API client across delayed file/CSRF
preparation, scope switches, duplicate calls, non-retryable failures, token entry,
unmount, archive compatibility and cleanup. `BackupTransferDialogs.test.tsx`
checks target/error disclosure and pending input/action states. Browser evidence
must exercise the production flow and consuming dialogs at desktop and narrow
widths with safe responses, not a replacement transfer implementation.
@@ -5468,6 +5468,7 @@
"frontend-modern/src/components/Settings/AuditLogPanel.tsx",
"frontend-modern/src/components/Settings/AuditWebhookPanel.tsx",
"frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx",
"frontend-modern/src/components/Settings/BackupTransferDialogs.tsx",
"frontend-modern/src/components/Settings/CopyCommandBlock.tsx",
"frontend-modern/src/components/Settings/DataHandlingPanel.tsx",
"frontend-modern/src/components/Settings/dataHandlingPanelModel.ts",
@@ -5512,6 +5513,7 @@
"frontend-modern/src/components/Settings/useAISettingsState.ts",
"frontend-modern/src/components/Settings/useAuditLogPanelState.ts",
"frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts",
"frontend-modern/src/components/Settings/useBackupTransferFlow.ts",
"frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts",
"frontend-modern/src/components/Settings/useDiscoverySettingsState.ts",
"frontend-modern/src/components/Settings/useReportingPanelState.ts",
@@ -5583,6 +5585,21 @@
"exact_files": [],
"require_explicit_path_policy_coverage": true,
"path_policies": [
{
"id": "settings-configuration-transfer",
"label": "configuration transfer target and dispatch proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Settings/BackupTransferDialogs.tsx",
"frontend-modern/src/components/Settings/useBackupTransferFlow.ts"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/Settings/__tests__/BackupTransferDialogs.test.tsx",
"frontend-modern/src/components/Settings/__tests__/useBackupTransferFlow.test.ts"
]
},
{
"id": "settings-update-install-guide",
"label": "settings update install guide proof",
@@ -0,0 +1,57 @@
{
"version": 1,
"base_sha": "d051c39bca60ba8cfbda0d07a07b74c228bc7eae",
"verified_at": "2026-10-25T06:19:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Settings/BackupTransferDialogs.tsx",
"frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx",
"frontend-modern/src/components/Settings/Settings.tsx",
"frontend-modern/src/components/Settings/SettingsDialogs.tsx",
"frontend-modern/src/components/Settings/useBackupTransferFlow.ts"
],
"content_sha256": {
"frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "48021e33b8243aa08e964bfa8d00dd458d862542290717bb1819cada34fb8bc1",
"frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx": "5326391538b6dd5abdb795753e645c32483a24947f91c2edaf03cdbfd28dd9db",
"frontend-modern/src/components/Settings/Settings.tsx": "fc11be5e7072f7c6e2c3e1004ead39aeb760aa9df689dc9f818dad1fdd0f4fcf",
"frontend-modern/src/components/Settings/SettingsDialogs.tsx": "3369aaaef68ef2fb84c448674a5b93dd8058994d4c35db44b09033123a4b2582",
"frontend-modern/src/components/Settings/useBackupTransferFlow.ts": "e114bdad70b09349aee656b36a0abd1ad9b80ce1a4343fb9333f6d664ed9b76d"
},
"routes": [
"/settings/system/recovery (offline fixture mounting production RecoverySettingsPanel, useBackupTransferFlow and SettingsDialogs/BackupTransferDialogs with real apiClient, Dialog, ToastContainer and CSS; not full app/auth)"
],
"states": [
"Workspace target retained from dialog open; changed scope before submit and during file read refuses dispatch; late export discarded; successful old-workspace import cannot reload the new selection.",
"One pending operation disables inputs, actions and dismissal; immutable file/passphrase sent to captured workspace.",
"HTTP invalid_org, CSRF and rate-limit responses make one application fetch and one server request, no default fallback or false success.",
"Token preflight and server-token-error prompts save a credential without submitting; deliberate manual submission remains usable.",
"Transport reset: one application fetch produces three same-workspace wire requests in each viewport; import remains unconfirmed and may have applied. This is not single-application protection.",
"Unmount during file read suppresses dispatch; populated export yields one byte-exact synthetic encrypted-envelope download at each viewport."
],
"interactions": [
"Desktop keyboard focus/Enter and narrow touch open the existing recovery dialogs; select file, enter masked passphrase and submit.",
"Delay original server responses and file reads; switch selected organisation via the real apiClient setter; assert captured scope and late-response suppression.",
"Inspect visible target, pending and persistent uncertain guidance; save a synthetic API token and explicitly submit, never auto-submit.",
"85 browser checks, 24 server request observations, two byte-exact downloads; all ten settled desktop/narrow captures inspected with native view_image."
],
"viewports": [
{
"width": 1440,
"height": 1000,
"browser": "Chromium",
"browser_version": "141.0.7390.37"
},
{
"width": 320,
"height": 940,
"browser": "Chromium",
"browser_version": "141.0.7390.37",
"touch": true
}
],
"evidence": "Current lane OUTCOME/RECEIPTS.md, browser-result.json, PIXEL-REVIEW.md and BROWSER-BINDING.json; Playwright 1.56.1",
"limitations": [
"Safe synthetic responses exercise the real frontend producer/consumer, not backend auth, archive cryptography, full app shell, private runtime, native recovery or release delivery.",
"Application retry opt-out is not server-side idempotency; Core assessment/repair retains the observed transport replay gap."
]
}
@@ -21,6 +21,9 @@ interface BackupTransferDialogsProps {
showExportDialog: Accessor<boolean>;
showImportDialog: Accessor<boolean>;
showApiTokenModal: Accessor<boolean>;
transferOrgID: Accessor<string>;
isTransferring: Accessor<boolean>;
transferError: Accessor<string>;
apiTokenInput: Accessor<string>;
setApiTokenInput: (value: string) => void;
handleExport: () => void;
@@ -43,6 +46,19 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
>
<Card padding="lg" class="max-w-md w-full">
<SectionHeader title="Export configuration" size="md" class="mb-4" />
<p class="mb-4 text-sm text-muted break-all">
Workspace ID: <strong class="text-base-content">{props.transferOrgID()}</strong>
</p>
<Show when={props.transferError()}>
<p role="alert" class="mb-4 text-sm text-red-700 dark:text-red-300">
{props.transferError()}
</p>
</Show>
<Show when={props.isTransferring()}>
<p role="status" class="mb-4 text-sm text-muted">
Exporting configuration. Keep this dialog open while Pulse waits for a response.
</p>
</Show>
<div class="space-y-4">
<Show when={props.securityStatus()?.hasAuthentication}>
@@ -51,6 +67,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
<label class="flex items-start gap-3 cursor-pointer">
<input
type="radio"
disabled={props.isTransferring()}
checked={!props.useCustomPassphrase()}
onChange={() => {
props.setUseCustomPassphrase(false);
@@ -71,6 +88,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
<label class="flex items-start gap-3 cursor-pointer">
<input
type="radio"
disabled={props.isTransferring()}
checked={props.useCustomPassphrase()}
onChange={() => props.setUseCustomPassphrase(true)}
class="mt-1 text-blue-600 focus:ring-blue-500"
@@ -98,6 +116,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
</label>
<input
id="backup-export-passphrase"
disabled={props.isTransferring()}
type="password"
value={props.exportPassphrase()}
onInput={(event) => props.setExportPassphrase(event.currentTarget.value)}
@@ -165,6 +184,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
<button
type="button"
onClick={props.closeExportDialog}
disabled={props.isTransferring()}
class="px-4 py-2 border border-border text-base-content rounded-md hover:bg-surface-hover"
>
Cancel
@@ -173,6 +193,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
type="button"
onClick={props.handleExport}
disabled={
props.isTransferring() ||
!props.exportPassphrase() ||
(props.useCustomPassphrase() && props.exportPassphrase().length < 12)
}
@@ -199,7 +220,8 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
<div class="space-y-4">
<p class="text-sm text-muted">
This Pulse instance requires an API token for export/import operations. Please enter
the API token configured on the server.
a token authorised for this workspace. Saving it does not repeat the transfer;
review the workspace and submit again yourself.
</p>
<div class={formField}>
@@ -254,6 +276,19 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
>
<Card padding="lg" class="max-w-md w-full">
<SectionHeader title="Import configuration" size="md" class="mb-4" />
<p class="mb-4 text-sm text-muted break-all">
Workspace ID: <strong class="text-base-content">{props.transferOrgID()}</strong>
</p>
<Show when={props.transferError()}>
<p role="alert" class="mb-4 text-sm text-red-700 dark:text-red-300">
{props.transferError()}
</p>
</Show>
<Show when={props.isTransferring()}>
<p role="status" class="mb-4 text-sm text-muted">
Importing configuration. Keep this dialog open while Pulse waits for a response.
</p>
</Show>
<div class="space-y-4">
<div class={formField}>
@@ -262,6 +297,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
</label>
<input
id="backup-import-file"
disabled={props.isTransferring()}
type="file"
accept=".json"
onChange={(event) => {
@@ -278,6 +314,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
</label>
<input
id="backup-import-passphrase"
disabled={props.isTransferring()}
type="password"
value={props.importPassphrase()}
onInput={(event) => props.setImportPassphrase(event.currentTarget.value)}
@@ -316,6 +353,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
<button
type="button"
onClick={props.closeImportDialog}
disabled={props.isTransferring()}
class="px-4 py-2 border border-border text-base-content rounded-md hover:bg-surface-hover"
>
Cancel
@@ -323,7 +361,9 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
<button
type="button"
onClick={props.handleImport}
disabled={!props.importPassphrase() || !props.importFile()}
disabled={
props.isTransferring() || !props.importPassphrase() || !props.importFile()
}
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Import
@@ -23,9 +23,9 @@ export interface RecoverySettingsPanelProps {
// Export/Import handlers
showExportDialog: Accessor<boolean>;
setShowExportDialog: Setter<boolean>;
setShowExportDialog: (open: boolean) => void;
showImportDialog: Accessor<boolean>;
setShowImportDialog: Setter<boolean>;
setShowImportDialog: (open: boolean) => void;
setUseCustomPassphrase: Setter<boolean>;
securityStatus: Accessor<{ hasAuthentication: boolean } | null>;
}
@@ -228,6 +228,9 @@ const SettingsWorkspace: Component<SettingsProps> = (props) => {
showExportDialog={backupTransferFlow.showExportDialog}
showImportDialog={backupTransferFlow.showImportDialog}
showApiTokenModal={backupTransferFlow.showApiTokenModal}
transferOrgID={backupTransferFlow.transferOrgID}
isTransferring={backupTransferFlow.isTransferring}
transferError={backupTransferFlow.transferError}
apiTokenInput={backupTransferFlow.apiTokenInput}
setApiTokenInput={backupTransferFlow.setApiTokenInput}
handleExport={backupTransferFlow.handleExport}
@@ -25,6 +25,9 @@ interface SettingsDialogsProps {
showExportDialog: Accessor<boolean>;
showImportDialog: Accessor<boolean>;
showApiTokenModal: Accessor<boolean>;
transferOrgID: Accessor<string>;
isTransferring: Accessor<boolean>;
transferError: Accessor<string>;
apiTokenInput: Accessor<string>;
setApiTokenInput: (value: string) => void;
handleExport: () => void;
@@ -72,6 +75,9 @@ export const SettingsDialogs: Component<SettingsDialogsProps> = (props) => {
showExportDialog={props.showExportDialog}
showImportDialog={props.showImportDialog}
showApiTokenModal={props.showApiTokenModal}
transferOrgID={props.transferOrgID}
isTransferring={props.isTransferring}
transferError={props.transferError}
apiTokenInput={props.apiTokenInput}
setApiTokenInput={props.setApiTokenInput}
handleExport={props.handleExport}
@@ -2,7 +2,7 @@ import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BackupTransferDialogs } from '../BackupTransferDialogs';
function renderTransfer(kind: 'export' | 'import') {
function renderTransfer(kind: 'export' | 'import', pending = false, error = '') {
return render(() => (
<BackupTransferDialogs
securityStatus={() => null}
@@ -17,6 +17,9 @@ function renderTransfer(kind: 'export' | 'import') {
showExportDialog={() => kind === 'export'}
showImportDialog={() => kind === 'import'}
showApiTokenModal={() => false}
transferOrgID={() => 'tenant-fixture'}
isTransferring={() => pending}
transferError={() => error}
apiTokenInput={() => ''}
setApiTokenInput={vi.fn()}
handleExport={vi.fn()}
@@ -63,4 +66,28 @@ describe('BackupTransferDialogs', () => {
expect(guide).toHaveAttribute('target', '_blank');
expect(guide).toHaveAttribute('rel', 'noopener noreferrer');
});
it.each(['export', 'import'] as const)(
'shows the fixed workspace, disables pending %s inputs and exposes progress',
(kind) => {
renderTransfer(kind, true);
expect(screen.getByText('tenant-fixture')).toBeInTheDocument();
const dialog = screen.getByRole('dialog');
for (const control of dialog.querySelectorAll('input, button')) {
expect(control).toBeDisabled();
}
expect(screen.getByRole('status')).toHaveTextContent('while Pulse waits for a response');
},
);
it('keeps uncertain outcome guidance readable in the import dialog', () => {
renderTransfer(
'import',
false,
'Import was not confirmed. Check the destination before trying again.',
);
expect(screen.getByRole('alert')).toHaveTextContent(
'Check the destination before trying again',
);
});
});
@@ -116,6 +116,13 @@ const settingsRuntimeSources = import.meta.glob(['../*.tsx', '../ConnectionEdito
}) as Record<string, string>;
describe('settings architecture guardrails', () => {
it('carries transfer target, pending and error state through the Settings dialog shell', () => {
for (const name of ['transferOrgID', 'isTransferring', 'transferError']) {
expect(settingsSource).toContain(`${name}={backupTransferFlow.${name}}`);
expect(settingsDialogsSource).toContain(`${name}={props.${name}}`);
}
});
it('keeps the retired Assistant chat controls and protected guests list out of AI settings', () => {
expect(aiRuntimeControlsSectionSource).not.toContain('Chat action mode');
expect(aiRuntimeControlsSectionSource).not.toContain('controlLevel');
@@ -0,0 +1,341 @@
import { createRoot } from 'solid-js';
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
import { apiClient, clearApiToken, getApiToken, getOrgID, setOrgID } from '@/utils/apiClient';
import { notificationStore } from '@/stores/notifications';
import type { SecurityStatus } from '@/types/config';
import { useBackupTransferFlow } from '../useBackupTransferFlow';
vi.mock('@/stores/notifications', () => ({
notificationStore: { error: vi.fn(), success: vi.fn(), info: vi.fn() },
}));
const fetchMock = vi.fn();
const disposers: (() => void)[] = [];
const passphrase = 'synthetic-passphrase';
let timeoutSpy: MockInstance<typeof globalThis.setTimeout>;
const reloadTimers = () =>
timeoutSpy.mock.calls.flatMap((args, index) =>
args[1] === 2000 ? [timeoutSpy.mock.results[index].value] : [],
);
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function setup(kind: 'export' | 'import' = 'import', tokenOnly = false) {
let dispose!: () => void;
const flow = createRoot((cleanup) => {
dispose = cleanup;
return useBackupTransferFlow({
securityStatus: () =>
({ hasAuthentication: !tokenOnly, apiTokenConfigured: tokenOnly }) as SecurityStatus,
});
});
disposers.push(dispose);
if (kind === 'import') flow.setShowImportDialog(true);
else flow.setShowExportDialog(true);
flow.setImportPassphrase(passphrase);
flow.setExportPassphrase(passphrase);
const read = vi.fn().mockResolvedValue('{"data":"synthetic-archive"}');
flow.setImportFile({ text: read } as unknown as File);
return { flow, read, dispose };
}
const response = (status = 200, body: unknown = { status: 'success', data: 'synthetic-archive' }) =>
new Response(JSON.stringify(body), {
status,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': 'replacement-csrf',
'Retry-After': '1',
},
});
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
fetchMock.mockReset().mockResolvedValue(response());
vi.stubGlobal('fetch', fetchMock);
clearApiToken();
setOrgID('tenant-a');
document.cookie = 'pulse_csrf=synthetic-csrf; Path=/';
vi.stubGlobal(
'URL',
class extends URL {
static createObjectURL = vi.fn(() => 'blob:synthetic');
static revokeObjectURL = vi.fn();
},
);
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
});
afterEach(() => {
for (const dispose of disposers.splice(0)) dispose();
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
clearApiToken();
setOrgID(null);
});
describe('configuration transfers keep their confirmed workspace and one dispatch', () => {
it.each(['export', 'import'] as const)(
'does not send %s after the workspace changes before confirmation',
async (kind) => {
const { flow } = setup(kind);
setOrgID('tenant-b');
await (kind === 'import' ? flow.handleImport() : flow.handleExport());
expect(fetchMock).not.toHaveBeenCalled();
expect(flow.transferOrgID()).toBe('tenant-a');
expect(notificationStore.error).toHaveBeenCalledWith(
expect.stringContaining('Organisation changed'),
);
},
);
it('does not retarget a slow file read to the newly selected workspace', async () => {
const { flow, read } = setup();
const file = deferred<string>();
read.mockReturnValue(file.promise);
const pending = flow.handleImport();
setOrgID('tenant-b');
file.resolve('{"data":"synthetic-archive"}');
await pending;
expect(fetchMock).not.toHaveBeenCalled();
expect(notificationStore.success).not.toHaveBeenCalled();
});
it('rechecks the captured target after asynchronous CSRF preparation', async () => {
const { flow } = setup();
const csrf = deferred<string>();
vi.spyOn(apiClient as any, 'loadCSRFToken').mockReturnValue(null);
const prepare = vi.spyOn(apiClient as any, 'ensureCSRFToken').mockReturnValue(csrf.promise);
const pending = flow.handleImport();
await vi.waitFor(() => expect(prepare).toHaveBeenCalled());
setOrgID('tenant-b');
csrf.resolve('synthetic-csrf');
await pending;
expect(fetchMock).not.toHaveBeenCalled();
expect(flow.transferError()).toContain('Organisation changed');
});
it.each(['export', 'import'] as const)(
'does not replay %s on invalid_org, CSRF or rate-limit responses',
async (kind) => {
for (const status of [400, 403, 429]) {
fetchMock.mockReset().mockResolvedValue(response(status, { error: 'invalid_org' }));
const { flow } = setup(kind);
await (kind === 'import' ? flow.handleImport() : flow.handleExport());
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][1].headers['X-Pulse-Org-ID']).toBe('tenant-a');
expect(getOrgID()).toBe('tenant-a');
expect(notificationStore.success).not.toHaveBeenCalled();
}
},
);
it('takes one immutable file/passphrase snapshot and blocks double submission', async () => {
const { flow, read } = setup();
const file = deferred<string>();
read.mockReturnValue(file.promise);
const pending = flow.handleImport();
flow.setImportPassphrase('edited-after-submit');
flow.setImportFile({ text: vi.fn() } as unknown as File);
await flow.handleImport();
flow.closeImportDialog();
expect(flow.showImportDialog()).toBe(true);
expect(flow.isTransferring()).toBe(true);
file.resolve('{"data":"synthetic-archive"}');
await pending;
expect(read).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, options] = fetchMock.mock.calls[0];
expect(JSON.parse(options.body)).toEqual({ passphrase, data: 'synthetic-archive' });
expect(options.headers['X-Pulse-Org-ID']).toBe('tenant-a');
expect(options.headers['X-CSRF-Token']).toBe('synthetic-csrf');
expect(options.credentials).toBe('include');
expect(flow.isTransferring()).toBe(false);
});
it.each(['export', 'import'] as const)(
'does not replay %s when an API token is entered',
async (kind) => {
const { flow } = setup(kind, true);
await (kind === 'import' ? flow.handleImport() : flow.handleExport());
expect(flow.showApiTokenModal()).toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
flow.setApiTokenInput('synthetic-token');
flow.handleApiTokenAuthenticate();
expect(getApiToken()).toBe('synthetic-token');
expect(fetchMock).not.toHaveBeenCalled();
await (kind === 'import' ? flow.handleImport() : flow.handleExport());
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][1].headers['X-API-Token']).toBe('synthetic-token');
},
);
it('keeps a token error visible and does not replay an uncertain import after token entry', async () => {
const { flow } = setup();
fetchMock.mockResolvedValue(response(403, { error: 'API token required' }));
await flow.handleImport();
expect(flow.showApiTokenModal()).toBe(true);
expect(flow.transferError()).toContain('Check the destination before trying again');
flow.setApiTokenInput('synthetic-token');
flow.handleApiTokenAuthenticate();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(flow.transferError()).toContain('Import was not confirmed');
});
it('does not save a prompted token in a different workspace', async () => {
const { flow } = setup('import', true);
await flow.handleImport();
flow.setApiTokenInput('synthetic-token');
setOrgID('tenant-b');
flow.handleApiTokenAuthenticate();
expect(getApiToken()).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it('does not download an export response into a different workspace', async () => {
const { flow } = setup('export');
const result = deferred<Response>();
fetchMock.mockReturnValue(result.promise);
const pending = flow.handleExport();
setOrgID('tenant-b');
result.resolve(response());
await pending;
expect(URL.createObjectURL).not.toHaveBeenCalled();
expect(notificationStore.success).not.toHaveBeenCalled();
expect(flow.exportPassphrase()).toBe(passphrase);
});
it('reports the original destination and never reloads a newly selected workspace after success', async () => {
const { flow } = setup();
const result = deferred<Response>();
fetchMock.mockReturnValue(result.promise);
const pending = flow.handleImport();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
setOrgID('tenant-b');
result.resolve(response());
await pending;
expect(notificationStore.success).not.toHaveBeenCalled();
expect(flow.transferError()).toContain('Import completed for workspace tenant-a');
expect(reloadTimers()).toHaveLength(0);
expect(flow.importPassphrase()).toBe('');
expect(flow.importFile()).toBeNull();
});
it.each(['export', 'import'] as const)(
'aborts %s and ignores a late response after unmount',
async (kind) => {
const { flow, dispose } = setup(kind);
const result = deferred<Response>();
fetchMock.mockReturnValue(result.promise);
const pending = kind === 'import' ? flow.handleImport() : flow.handleExport();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
dispose();
expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(true);
result.resolve(response());
await pending;
expect(notificationStore.success).not.toHaveBeenCalled();
expect(URL.createObjectURL).not.toHaveBeenCalled();
expect(reloadTimers()).toHaveLength(0);
},
);
it('does not dispatch a file read that finishes after unmount', async () => {
const { flow, read, dispose } = setup();
const file = deferred<string>();
read.mockReturnValue(file.promise);
const pending = flow.handleImport();
dispose();
file.resolve('{"data":"synthetic-archive"}');
await pending;
expect(fetchMock).not.toHaveBeenCalled();
});
it.each([
'{"status":"success","data":"synthetic-archive"}',
'{"data":"synthetic-archive"}',
'"synthetic-archive"',
'synthetic-archive\n',
])('preserves supported archive envelope %s and explicit default scope', async (contents) => {
setOrgID(null);
const { flow, read } = setup();
read.mockResolvedValue(contents);
await flow.handleImport();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][1].headers['X-Pulse-Org-ID']).toBe('default');
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
passphrase,
data: 'synthetic-archive',
});
expect(flow.showImportDialog()).toBe(false);
expect(flow.importFile()).toBeNull();
expect(flow.importPassphrase()).toBe('');
});
it.each(['null', '{}', '{"data":5}', '{"data":""}', ' '])(
'rejects invalid archive %s without network access',
async (contents) => {
const { flow, read } = setup();
read.mockResolvedValue(contents);
await flow.handleImport();
expect(fetchMock).not.toHaveBeenCalled();
expect(flow.transferError()).toContain('Invalid backup file format');
expect(flow.isTransferring()).toBe(false);
},
);
it('retains an uncertain response without success, reloading or a second request', async () => {
const { flow } = setup();
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
await flow.handleImport();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(flow.transferError()).toContain('Import was not confirmed');
expect(flow.showImportDialog()).toBe(true);
expect(reloadTimers()).toHaveLength(0);
expect(notificationStore.success).not.toHaveBeenCalled();
});
it('downloads the successful export once, releases the URL and clears the secret', async () => {
const { flow } = setup('export');
await flow.handleExport();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(1);
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:synthetic');
expect(flow.exportPassphrase()).toBe('');
expect(flow.showExportDialog()).toBe(false);
});
it('captures the new target only after the operator closes and reopens the dialog', () => {
const { flow } = setup();
flow.closeImportDialog();
setOrgID('tenant-b');
flow.setShowImportDialog(true);
expect(flow.transferOrgID()).toBe('tenant-b');
expect(flow.importFile()).toBeNull();
expect(flow.importPassphrase()).toBe('');
});
it.each(['leave', 'reopen'] as const)(
'cancels the actual successful-import reload timer on %s',
async (action) => {
const { flow, dispose } = setup();
await flow.handleImport();
expect(reloadTimers()).toHaveLength(1);
const cancel = vi.spyOn(globalThis, 'clearTimeout');
if (action === 'leave') dispose();
else flow.setShowExportDialog(true);
expect(cancel).toHaveBeenCalledWith(reloadTimers()[0]);
},
);
});
@@ -1,237 +1,257 @@
import { Accessor, createSignal } from 'solid-js';
import { Accessor, createSignal, onCleanup } from 'solid-js';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import {
apiFetchJSON,
getOrgID,
getApiToken as getApiClientToken,
setApiToken as setApiClientToken,
} from '@/utils/apiClient';
import type { SecurityStatus } from '@/types/config';
type ApiTokenModalSource = 'export' | 'import' | null;
interface UseBackupTransferFlowParams {
securityStatus: Accessor<SecurityStatus | null>;
}
const CHANGED_WORKSPACE =
'Organisation changed. Close this dialog and reopen it in the intended workspace.';
export function useBackupTransferFlow({ securityStatus }: UseBackupTransferFlowParams) {
const [exportPassphrase, setExportPassphrase] = createSignal('');
const [useCustomPassphrase, setUseCustomPassphrase] = createSignal(false);
const [importPassphrase, setImportPassphrase] = createSignal('');
const [importFile, setImportFile] = createSignal<File | null>(null);
const [showExportDialog, setShowExportDialog] = createSignal(false);
const [showImportDialog, setShowImportDialog] = createSignal(false);
const [showExportDialog, showExport] = createSignal(false);
const [showImportDialog, showImport] = createSignal(false);
const [showApiTokenModal, setShowApiTokenModal] = createSignal(false);
const [apiTokenInput, setApiTokenInput] = createSignal('');
const [apiTokenModalSource, setApiTokenModalSource] = createSignal<ApiTokenModalSource>(null);
const [transferOrgID, setTransferOrgID] = createSignal('default');
const [isTransferring, setIsTransferring] = createSignal(false);
const [transferError, setTransferError] = createSignal('');
let active = true;
let controller: AbortController | undefined;
let reloadTimer: ReturnType<typeof setTimeout> | undefined;
const closeExportDialog = () => {
setShowExportDialog(false);
setExportPassphrase('');
setUseCustomPassphrase(false);
onCleanup(() => {
active = false;
controller?.abort();
clearTimeout(reloadTimer);
});
const currentOrgID = () => getOrgID() || 'default';
const fail = (message: string) => {
setTransferError(message);
notificationStore.error(message);
};
const closeImportDialog = () => {
setShowImportDialog(false);
setImportPassphrase('');
setImportFile(null);
const checkWorkspace = () => {
if (currentOrgID() === transferOrgID()) return true;
fail(CHANGED_WORKSPACE);
return false;
};
const closeApiTokenModal = () => {
setShowApiTokenModal(false);
setApiTokenInput('');
};
const closeExportDialog = () => {
if (isTransferring()) return;
showExport(false);
setExportPassphrase('');
setUseCustomPassphrase(false);
closeApiTokenModal();
};
const closeImportDialog = () => {
if (isTransferring()) return;
showImport(false);
setImportPassphrase('');
setImportFile(null);
closeApiTokenModal();
};
const openDialog = (kind: 'export' | 'import') => {
if (isTransferring()) return;
clearTimeout(reloadTimer);
closeExportDialog();
closeImportDialog();
setTransferError('');
setTransferOrgID(currentOrgID());
if (kind === 'export') showExport(true);
else showImport(true);
};
const setShowExportDialog = (open: boolean) =>
open ? openDialog('export') : closeExportDialog();
const setShowImportDialog = (open: boolean) =>
open ? openDialog('import') : closeImportDialog();
const needsApiToken = () => {
if (
!securityStatus()?.hasAuthentication &&
securityStatus()?.apiTokenConfigured &&
!getApiClientToken()
) {
setShowApiTokenModal(true);
return true;
}
return false;
};
const handleExport = async () => {
if (!exportPassphrase()) {
const hasAuth = securityStatus()?.hasAuthentication;
notificationStore.error(
hasAuth
? useCustomPassphrase()
? 'Please enter a passphrase'
: 'Please enter your password'
if (!active || !showExportDialog() || isTransferring() || !checkWorkspace()) return;
setTransferError('');
const passphrase = exportPassphrase();
if (!passphrase) {
fail(
securityStatus()?.hasAuthentication && !useCustomPassphrase()
? 'Please enter your password'
: 'Please enter a passphrase',
);
return;
}
// Backend requires at least 12 characters for encryption security
if (exportPassphrase().length < 12) {
const hasAuth = securityStatus()?.hasAuthentication;
notificationStore.error(
hasAuth && !useCustomPassphrase()
if (passphrase.length < 12) {
fail(
securityStatus()?.hasAuthentication && !useCustomPassphrase()
? 'Your password must be at least 12 characters. Please use a custom passphrase instead.'
: 'Passphrase must be at least 12 characters long',
);
return;
}
if (needsApiToken()) return;
// Only check for API token if user is not authenticated via password
// If user is logged in with password, session auth is sufficient
const hasPasswordAuth = securityStatus()?.hasAuthentication;
if (!hasPasswordAuth && securityStatus()?.apiTokenConfigured && !getApiClientToken()) {
setApiTokenModalSource('export');
setShowApiTokenModal(true);
return;
}
const orgID = transferOrgID();
controller = new AbortController();
setIsTransferring(true);
try {
// Get CSRF token from cookie
const csrfCookie = document.cookie.split('; ').find((row) => row.startsWith('pulse_csrf='));
const csrfToken = csrfCookie
? decodeURIComponent(csrfCookie.split('=').slice(1).join('='))
: undefined;
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
// Add CSRF token if available
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
// Add API token if configured
const apiToken = getApiClientToken();
if (apiToken) {
headers['X-API-Token'] = apiToken;
}
const data = await apiFetchJSON<any>('/api/config/export', {
const data = await apiFetchJSON('/api/config/export', {
method: 'POST',
body: JSON.stringify({ passphrase: exportPassphrase() }),
body: JSON.stringify({ passphrase }),
expectedOrgID: orgID,
retry: false,
signal: controller.signal,
});
if (!active || !checkWorkspace()) return;
// Create and download file
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pulse-config-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
try {
a.href = url;
a.download = `pulse-config-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
} finally {
a.remove();
URL.revokeObjectURL(url);
}
notificationStore.success('Configuration exported successfully');
setShowExportDialog(false);
showExport(false);
setExportPassphrase('');
setUseCustomPassphrase(false);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to export configuration';
notificationStore.error(errorMessage);
logger.error('Export error', error);
if (active) fail(error instanceof Error ? error.message : 'Failed to export configuration');
} finally {
if (active) setIsTransferring(false);
}
};
const handleImport = async () => {
if (!importPassphrase()) {
notificationStore.error('Please enter the password');
if (!active || !showImportDialog() || isTransferring() || !checkWorkspace()) return;
setTransferError('');
const passphrase = importPassphrase();
const file = importFile();
if (!passphrase) {
fail('Please enter the password');
return;
}
if (!importFile()) {
notificationStore.error('Please select a file to import');
return;
}
// Only check for API token if user is not authenticated via password
// If user is logged in with password, session auth is sufficient
const hasPasswordAuth = securityStatus()?.hasAuthentication;
if (!hasPasswordAuth && securityStatus()?.apiTokenConfigured && !getApiClientToken()) {
setApiTokenModalSource('import');
setShowApiTokenModal(true);
if (!file) {
fail('Please select a file to import');
return;
}
if (needsApiToken()) return;
// Freeze the confirmed target and inputs before the asynchronous file read.
const orgID = transferOrgID();
controller = new AbortController();
const signal = controller.signal;
setIsTransferring(true);
let submitted = false;
try {
const fileContent = await importFile()!.text();
const fileContent = await file.text();
if (!active || !checkWorkspace()) return;
// Support three formats:
// 1. UI export: {status: "success", data: "base64string"}
// 2. Legacy format: {data: "base64string"}
// 3. CLI export: raw base64 string (no JSON wrapper)
let encryptedData: string;
// Try to parse as JSON first
// UI/legacy JSON envelopes, JSON strings and raw CLI base64 remain supported.
let encryptedData: unknown;
try {
const exportData = JSON.parse(fileContent);
if (typeof exportData === 'string') {
// Raw base64 string wrapped in JSON (edge case)
encryptedData = exportData;
} else if (exportData.data) {
// Standard format with data field
encryptedData = exportData.data;
} else {
notificationStore.error(
'Invalid backup file format. Expected encrypted data in "data" field.',
);
return;
}
} catch (_parseError) {
// Not JSON - treat entire contents as raw base64 from CLI export
const parsed: unknown = JSON.parse(fileContent);
encryptedData =
typeof parsed === 'string'
? parsed
: parsed && typeof parsed === 'object' && 'data' in parsed
? parsed.data
: undefined;
} catch {
encryptedData = fileContent.trim();
}
await apiFetchJSON('/api/config/import', {
method: 'POST',
body: JSON.stringify({
passphrase: importPassphrase(),
data: encryptedData,
}),
});
notificationStore.success('Configuration imported successfully. Reloading...');
setShowImportDialog(false);
setImportPassphrase('');
setImportFile(null);
// Reload page to apply new configuration
setTimeout(() => window.location.reload(), 2000);
} catch (error) {
const errorText = error instanceof Error ? error.message : String(error);
// Handle specific error cases if possible, though apiFetch usually handles 401/403
// But for Import, we might want to trigger the token modal if it was a token issue
// Note: apiFetch throws Error with message.
const normalizedError = errorText.toLowerCase();
if (
errorText.includes('API_TOKEN') ||
errorText.includes('API_TOKENS') ||
normalizedError.includes('api token') ||
normalizedError.includes('x-api-token')
) {
setApiTokenModalSource('import');
setShowApiTokenModal(true);
if (typeof encryptedData !== 'string' || !encryptedData.trim()) {
fail('Invalid backup file format. Expected encrypted data in "data" field.');
return;
}
notificationStore.error(errorText || 'Failed to import configuration');
logger.error('Import error', error);
submitted = true;
await apiFetchJSON('/api/config/import', {
method: 'POST',
body: JSON.stringify({ passphrase, data: encryptedData }),
expectedOrgID: orgID,
retry: false,
signal,
});
if (!active) return;
if (currentOrgID() !== orgID) {
fail(
`Import completed for workspace ${orgID}. The selected workspace changed; no reload was performed.`,
);
showImport(false);
setImportPassphrase('');
setImportFile(null);
return;
}
notificationStore.success('Configuration imported successfully. Reloading...');
showImport(false);
setImportPassphrase('');
setImportFile(null);
// Never reload a different workspace or an unmounted Settings view.
reloadTimer = setTimeout(() => {
if (active && currentOrgID() === orgID) window.location.reload();
}, 2000);
} catch (error) {
if (!active) return;
const errorText = error instanceof Error ? error.message : 'Failed to import configuration';
fail(
submitted
? `${errorText} Import was not confirmed and may have applied. Check the destination before trying again.`
: errorText,
);
if (/api[_ -]?tokens?|x-api-token/i.test(errorText)) setShowApiTokenModal(true);
} finally {
if (active) setIsTransferring(false);
}
};
const handleApiTokenAuthenticate = () => {
if (!apiTokenInput()) {
notificationStore.error('Please enter the API token');
if (!active || isTransferring() || !checkWorkspace()) {
closeApiTokenModal();
return;
}
const tokenValue = apiTokenInput()!;
setApiClientToken(tokenValue);
const source = apiTokenModalSource();
setShowApiTokenModal(false);
setApiTokenInput('');
setApiTokenModalSource(null);
// Retry the operation that triggered the modal
if (source === 'export') {
void handleExport();
} else if (source === 'import') {
void handleImport();
if (!apiTokenInput().trim()) {
fail('Please enter the API token');
return;
}
setApiClientToken(apiTokenInput());
if (!getApiClientToken()) {
fail('Please enter a valid API token');
return;
}
closeApiTokenModal();
// Saving a credential is not consent to replay a possibly applied import.
notificationStore.info('API token saved. Review the workspace and submit the transfer again.');
};
return {
@@ -250,6 +270,9 @@ export function useBackupTransferFlow({ securityStatus }: UseBackupTransferFlowP
showApiTokenModal,
apiTokenInput,
setApiTokenInput,
transferOrgID,
isTransferring,
transferError,
handleExport,
handleImport,
closeExportDialog,