diff --git a/lib/shared-store/__tests__/import-export.test.ts b/lib/shared-store/__tests__/import-export.test.ts index ebcebc197..1fb67772b 100644 --- a/lib/shared-store/__tests__/import-export.test.ts +++ b/lib/shared-store/__tests__/import-export.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { createHash, randomBytes } from 'node:crypto'; +import { createHash, generateKeyPairSync, randomBytes } from 'node:crypto'; import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -76,6 +76,13 @@ describe('moving an install onto the shared store and back', () => { writeFileSync(path.join(a.PLUGIN_SECRETS_DIR, vaultFile), randomBytes(120).toString('base64')); writeFileSync(path.join(a.TELEMETRY_DATA_DIR, 'state.json'), JSON.stringify({ consent: 'on' }, null, 2)); writeFileSync(path.join(a.VERSION_CHECK_DATA_DIR, 'state.json'), JSON.stringify({ lastCheckedAt: '2026-11-01' }, null, 2)); + mkdirSync(path.join(a.ADMIN_CONFIG_DIR, 'plugins')); + writeFileSync(path.join(a.ADMIN_CONFIG_DIR, 'plugins', 'registry.json'), JSON.stringify({ plugins: [{ id: 'demo' }] }, null, 2)); + writeFileSync(path.join(a.ADMIN_CONFIG_DIR, 'plugins', 'demo.js'), 'module.exports = {}'); + mkdirSync(path.join(a.ADMIN_CONFIG_DIR, 'branding')); + writeFileSync(path.join(a.ADMIN_CONFIG_DIR, 'branding', 'faviconUrl.png'), randomBytes(64)); + const signingKey = generateKeyPairSync('ed25519').privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; + writeFileSync(path.join(a.ADMIN_CONFIG_DIR, 'plugin-signing.key'), signingKey); const before = snapshot(a.base); const first = await importLocalData(); @@ -87,6 +94,9 @@ describe('moving an install onto the shared store and back', () => { 'plugin-secrets': 1, telemetry: 1, 'version-check': 1, + plugins: 2, + branding: 1, + 'plugin-signing-key': 1, }); const second = await importLocalData(); expect(Object.values(second.imported).every((n) => n === 0), JSON.stringify(second.imported)).toBe(true); @@ -98,6 +108,7 @@ describe('moving an install onto the shared store and back', () => { const b = install('b'); const written = await exportSharedData(); expect(written['settings']).toBe(1); + expect(written).toMatchObject({ plugins: 2, branding: 1, 'plugin-signing-key': 1 }); const after = snapshot(b.base); for (const [file, digest] of Object.entries(before)) { expect(after[file], `${file} exported`).toBe(digest); diff --git a/lib/shared-store/export.ts b/lib/shared-store/export.ts index 083090920..d14775c0f 100644 --- a/lib/shared-store/export.ts +++ b/lib/shared-store/export.ts @@ -12,6 +12,7 @@ import { ARCHIVE_COLLECTION } from '@/lib/account-archive-storage'; import { TELEMETRY_COLLECTION } from '@/lib/telemetry/state'; import { VERSION_CHECK_COLLECTION } from '@/lib/version-check/state'; import { getStore } from './index'; +import { exportSteps as extExportSteps } from './import/ext'; import { localCollection, localLog } from './local'; import type { CollectionOptions } from './types'; @@ -81,6 +82,9 @@ export const EXPORT_STEPS: ExportStep[] = [ secretStep, exportCollection(ADMIN_CONFIG_COLLECTION, adminConfigOptions), exportCollection(ADMIN_STATE_COLLECTION, adminStateOptions), + // After admin-state: directory-keyrings.json is in that collection too and + // is written again owner-only here. + ...extExportSteps, auditStep, exportCollection(SESSION_LIMITS_COLLECTION, sessionLimitsLayout), exportCollection(WOPI_COLLECTION, wopiRevocationsLayout), diff --git a/lib/shared-store/import.ts b/lib/shared-store/import.ts index 281c34439..53b47a8e4 100644 --- a/lib/shared-store/import.ts +++ b/lib/shared-store/import.ts @@ -2,6 +2,7 @@ import type { ImportSummary } from './boot'; import { steps as adminSteps } from './import/admin'; import { steps as authSteps } from './import/auth'; import { steps as dataSteps } from './import/data'; +import { steps as extSteps } from './import/ext'; /** * Copy an existing single-instance install's local files into an @@ -16,8 +17,8 @@ export interface ImportStep { run(): Promise; } -/** In this order: config first, then state, then user data. */ -const STEPS: ImportStep[] = [...adminSteps, ...authSteps, ...dataSteps]; +/** In this order: config first (admin, then plugins, themes, branding), then state, then user data. */ +const STEPS: ImportStep[] = [...adminSteps, ...extSteps, ...authSteps, ...dataSteps]; export async function importLocalData(steps: ImportStep[] = STEPS): Promise { const imported: Record = {};