Preserve configuration encryption edits when applying settings

This commit is contained in:
vorotamoroz
2026-10-13 04:18:57 +00:00
parent 14141446a1
commit db9f7af4f1
11 changed files with 720 additions and 71 deletions
+3
View File
@@ -74,6 +74,7 @@ After changing plug-in source, use the focused wrapper rather than invoking a sc
```bash
npm run test:e2e:obsidian:focused -- settings-ui
npm run test:e2e:obsidian:focused -- configuration-encryption
npm run test:e2e:obsidian:focused -- two-vault-sync
npm run test:e2e:obsidian:focused -- stale-file-restart
npm run test:e2e:obsidian:focused -- folder-batch
@@ -111,6 +112,8 @@ The underlying `test:e2e:obsidian:<scenario>` scripts remain available for an im
`test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then reopens the wizard from Quick Setup in either settings interface. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios.
`configuration-encryption` needs no remote service. It applies a custom configuration key through the settings interface, restarts the same isolated Vault, and applies a change to only the custom string. It then copies the encrypted settings into a fresh profile without that key and checks masked input, wrong-input retry, cancellation without a settings write or database initialisation, re-enabling for another attempt, access to the settings tab, and restoration after a further restart. Connection, E2EE, profile, and independent ID values must remain unchanged. These checks establish ordinary Apply and restart behaviour, without simulating process termination between the device-key and settings writes.
`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database.
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it verifies the task-oriented landing order, the separate Synchronisation group containing Remote Configuration and Sync Settings, and the General Settings group containing Appearance, Logging, and Extra menus. It reopens and cancels onboarding from Quick Setup, opens all 14 nested settings pages, searches globally for the Advanced control, captures the landing page, search result, Advanced page, and mobile landing page, and verifies that the value remains after the settings dialogue is closed and reopened. The mobile landing check confirms that Remote Configuration is reachable without initial scrolling. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
@@ -0,0 +1,395 @@
import assert from "node:assert/strict";
import { mkdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import type { Locator, Page } from "playwright";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { openLiveSyncSettings, waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const coreExpression = "app.plugins.plugins['obsidian-livesync'].core";
const firstKey = "synthetic-first-configuration-key";
const secondKey = "synthetic-second-configuration-key";
const diagnosticsDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
const screenshots: string[] = [];
const saveFailureMessage =
"Configuration encryption could not be saved. Verify the saved settings and device key before retrying.";
const secrets = {
couchDB_URI: "https://synthetic.invalid",
couchDB_PASSWORD: "synthetic-connection-password",
passphrase: "%synthetic-e2ee-passphrase",
idDerivationKey: "ab".repeat(32),
profile: "sls+http://synthetic-user:synthetic-password@synthetic.invalid/?db=synthetic-database",
};
async function readSecrets(cli: string, session: ObsidianLiveSyncSession): Promise<typeof secrets> {
return await evalObsidianJson(
cli,
`(()=>{
const settings=${coreExpression}.services.setting.currentSettings();
return JSON.stringify({couchDB_URI:settings.couchDB_URI,couchDB_PASSWORD:settings.couchDB_PASSWORD,
passphrase:settings.passphrase,idDerivationKey:settings.idDerivationKey,
profile:settings.remoteConfigurations.inactive.uri});
})()`,
session.cliEnv
);
}
async function capture(locator: Locator, filename: string): Promise<void> {
await mkdir(diagnosticsDirectory, { recursive: true });
const path = join(diagnosticsDirectory, filename);
await locator.screenshot({ path, animations: "disabled", caret: "hide" });
screenshots.push(path);
}
async function assertPendingLoad(
cli: string,
env: NodeJS.ProcessEnv,
expectedKey: string | null = null
): Promise<void> {
const state = await evalObsidianJson(
cli,
`(()=>{
const core=${coreExpression};
return JSON.stringify({databaseReady:core.services.database.isDatabaseReady(),
tabRegistered:core.modules.some(module=>!!module.settingTab),
key:localStorage.getItem('ls-setting-passphrase')});
})()`,
env
);
assert.deepEqual(state, { databaseReady: false, tabRegistered: false, key: expectedKey });
}
async function applyCustomKey(session: ObsidianLiveSyncSession, key: string, changeMode: boolean): Promise<void> {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
try {
const navigator = await openLiveSyncSettings(page);
const root = await navigator.openPage("Power users");
if (changeMode) {
await root
.locator(".setting-item")
.filter({ hasText: "Encrypting sensitive configuration items" })
.getByRole("combobox")
.selectOption("LOCALSTORAGE");
}
await root.locator('input[type="password"]:visible').fill(key);
await capture(
navigator.dialogue,
changeMode ? "02-custom-before-apply.png" : "04-custom-key-only-edit.png"
);
await root.getByRole("button", { name: "Apply", exact: true }).click();
await page.waitForFunction(`(()=>{
const core=${coreExpression};
const tab=core.modules.find(module=>module.settingTab)?.settingTab;
return tab && !tab.isDirty('configPassphrase') && !tab.isDirty('configPassphraseStore') &&
core.settings.configPassphraseStore==='LOCALSTORAGE' &&
localStorage.getItem('ls-setting-passphrase')===${JSON.stringify(key)};
})()`);
await capture(navigator.dialogue, changeMode ? "03-custom-applied.png" : "05-custom-key-only-applied.png");
await navigator.close();
} catch (error) {
await mkdir(diagnosticsDirectory, { recursive: true });
await page.screenshot({
path: join(diagnosticsDirectory, "configuration-apply.failure.png"),
fullPage: true,
});
throw error;
}
});
}
async function assertDefaultSettings(session: ObsidianLiveSyncSession): Promise<void> {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
assert.equal(await page.locator('.modal-container input[type="password"]').count(), 0);
const navigator = await openLiveSyncSettings(page);
const root = await navigator.openPage("Power users");
const store = root
.locator(".setting-item")
.filter({ hasText: "Encrypting sensitive configuration items" })
.getByRole("combobox");
assert.equal(await store.inputValue(), "");
assert.equal(await root.locator('input[type="password"]:visible').isDisabled(), true);
await capture(navigator.dialogue, "01-default-after-restart.png");
await navigator.close();
});
}
async function assertRejectedEmptyKey(cli: string, session: ObsidianLiveSyncSession, dataPath: string): Promise<void> {
const before = await readFile(dataPath, "utf8");
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const navigator = await openLiveSyncSettings(page);
const root = await navigator.openPage("Power users");
await root.locator('input[type="password"]:visible').fill("");
await root.getByRole("button", { name: "Apply", exact: true }).click();
let notice: Locator | undefined;
const deadline = Date.now() + 5000;
while (Date.now() < deadline && !notice) {
for (const candidate of page.context().pages()) {
const matching = candidate.locator(".notice").filter({ hasText: saveFailureMessage }).last();
if (await matching.isVisible()) {
notice = matching;
break;
}
}
if (!notice) await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.ok(notice, "The rejected Apply did not display its failure notice");
await capture(notice, "06-apply-failure-notice.png");
const state = await evalObsidianJson(
cli,
`(()=>{
const core=${coreExpression};
const tab=core.modules.find(module=>module.settingTab)?.settingTab;
return JSON.stringify({key:localStorage.getItem('ls-setting-passphrase'),
store:core.settings.configPassphraseStore,dirty:tab.isDirty('configPassphrase')});
})()`,
session.cliEnv
);
assert.deepEqual(state, { key: secondKey, store: "LOCALSTORAGE", dirty: true });
assert.equal(await readFile(dataPath, "utf8"), before);
await capture(navigator.dialogue, "07-apply-failure-edits-retained.png");
await root.locator('input[type="password"]:visible').fill(secondKey);
await navigator.close();
});
}
async function submitKey(page: Page, title: string, key: string): Promise<void> {
const dialogue = await waitForVisibleObsidianDialogue(page, title);
await dialogue.locator('input[type="password"]').fill(key);
await dialogue.getByRole("button", { name: "OK", exact: true }).click();
}
async function applyAskMode(session: ObsidianLiveSyncSession): Promise<void> {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const navigator = await openLiveSyncSettings(page);
const root = await navigator.openPage("Power users");
await root
.locator(".setting-item")
.filter({ hasText: "Encrypting sensitive configuration items" })
.getByRole("combobox")
.selectOption("ASK_AT_LAUNCH");
await root.getByRole("button", { name: "Apply", exact: true }).click();
await submitKey(page, "Configuration passphrase", secondKey);
await page.waitForFunction(`(()=>{
const core=${coreExpression};
const tab=core.modules.find(module=>module.settingTab)?.settingTab;
return tab && !tab.isDirty('configPassphraseStore') && core.settings.configPassphraseStore==='ASK_AT_LAUNCH';
})()`);
await navigator.close();
});
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked: ${cli.checked.join(", ")}`);
const cliBinary = cli.binary;
const firstVault = await createTemporaryVault("obsidian-livesync-configuration-source-");
let secondVault: Awaited<ReturnType<typeof createTemporaryVault>> | undefined;
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault: firstVault,
pluginData: {
doctorProcessedVersion: "1.0.35",
isConfigured: true,
remoteType: "",
usePowerUserMode: true,
liveSync: false,
periodicReplication: false,
syncOnStart: false,
syncOnSave: false,
syncOnFileOpen: false,
syncOnEditorSave: false,
syncAfterMerge: false,
displayLanguage: "",
notifyThresholdOfRemoteStorageSize: -1,
},
localStorageEntries: createE2eObsidianDeviceLocalState(firstVault.name),
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await evalObsidianJson(
cliBinary,
`(async()=>{
const core=${coreExpression};
await core.services.setting.applyPartial({encrypt:true,idDerivationVersion:1,
idDerivationKey:${JSON.stringify(secrets.idDerivationKey)},passphrase:${JSON.stringify(secrets.passphrase)},
couchDB_URI:${JSON.stringify(secrets.couchDB_URI)},couchDB_DBNAME:'synthetic-database',
couchDB_PASSWORD:${JSON.stringify(secrets.couchDB_PASSWORD)},activeConfigurationId:'',
remoteConfigurations:{inactive:{id:'inactive',name:'Synthetic profile',
uri:${JSON.stringify(secrets.profile)},isEncrypted:false}}},true);
return JSON.stringify(true);
})()`,
session.cliEnv
);
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: firstVault });
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
await evalObsidianJson(
cliBinary,
`(async()=>{
await ${coreExpression}.services.setting.saveSettingData();
return JSON.stringify(true);
})()`,
session.cliEnv
);
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: firstVault });
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
await assertDefaultSettings(session);
console.log(
"Configuration encryption: Default save and restart preserved all protected values without a key prompt."
);
await applyCustomKey(session, firstKey, true);
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
console.log("Configuration encryption: mode and custom key applied through the settings interface.");
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: firstVault });
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
await applyCustomKey(session, secondKey, false);
console.log("Configuration encryption: restart and custom-key-only Apply preserved all protected values.");
const sourceDataPath = join(firstVault.path, ".obsidian/plugins/obsidian-livesync/data.json");
const encryptedData = await readFile(sourceDataPath, "utf8");
const saved = JSON.parse(encryptedData) as Record<string, unknown>;
assert.equal(saved.configPassphraseStore, "LOCALSTORAGE");
assert.equal(saved.idDerivationKey, "");
assert.equal(saved.passphrase, "");
assert.equal(saved.couchDB_PASSWORD, "");
for (const value of Object.values(secrets)) assert.equal(encryptedData.includes(value), false);
await assertRejectedEmptyKey(cliBinary, session, sourceDataPath);
console.log(
"Configuration encryption: rejected empty-key Apply displayed a notice and retained the device key, saved data, and pending edit."
);
await session.app.stop();
session = undefined;
secondVault = await createTemporaryVault("obsidian-livesync-configuration-copy-");
const copyDataPath = join(secondVault.path, ".obsidian/plugins/obsidian-livesync/data.json");
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault: secondVault,
pluginData: saved,
localStorageEntries: createE2eObsidianDeviceLocalState(secondVault.name),
lifecycle: {
afterPluginLoad: async ({ remoteDebuggingPort, cliEnv }) => {
await withObsidianPage(remoteDebuggingPort, async (page) => {
try {
const missingKey = await waitForVisibleObsidianDialogue(page, "Configuration passphrase");
const missingInput = missingKey.locator('input[type="password"]');
assert.equal(await missingInput.getAttribute("type"), "password");
await missingInput.fill("synthetic-wrong-key");
await capture(missingKey.locator(".modal"), "08-missing-custom-key.png");
await assertPendingLoad(cliBinary, cliEnv);
const beforeInput = await readFile(copyDataPath, "utf8");
await submitKey(page, "Configuration passphrase", "synthetic-wrong-key");
const retry = await waitForVisibleObsidianDialogue(
page,
"Configuration could not be decrypted"
);
await retry.locator('input[type="password"]').fill(secondKey);
await capture(retry.locator(".modal"), "09-wrong-key-retry.png");
await assertPendingLoad(cliBinary, cliEnv);
assert.equal(await readFile(copyDataPath, "utf8"), beforeInput);
await retry.getByRole("button", { name: "Cancel", exact: true }).click();
await retry.waitFor({ state: "hidden" });
await assertPendingLoad(cliBinary, cliEnv);
assert.equal(await readFile(copyDataPath, "utf8"), beforeInput);
await evalObsidianJson(
cliBinary,
`(async()=>{
await app.plugins.disablePlugin('obsidian-livesync');
await app.plugins.enablePlugin('obsidian-livesync');
return JSON.stringify(true);
})()`,
cliEnv
);
await submitKey(page, "Configuration passphrase", secondKey);
} catch (error) {
await mkdir(diagnosticsDirectory, { recursive: true });
await page.screenshot({
path: join(diagnosticsDirectory, "configuration-unlock.failure.png"),
fullPage: true,
});
throw error;
}
});
},
},
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const navigator = await openLiveSyncSettings(page);
await navigator.openPage("Power users");
await capture(navigator.dialogue, "10-unlocked-settings.png");
await navigator.close();
});
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: secondVault });
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
console.log(
"Configuration encryption: Apply and custom-key-only Apply survive restart; copied settings unlock after wrong input, cancellation, and retry; the validated key survives another restart."
);
await applyAskMode(session);
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault: secondVault,
lifecycle: {
afterPluginLoad: async ({ remoteDebuggingPort, cliEnv }) => {
await withObsidianPage(remoteDebuggingPort, async (page) => {
const dialogue = await waitForVisibleObsidianDialogue(page, "Configuration passphrase");
const input = dialogue.locator('input[type="password"]');
assert.equal(await input.getAttribute("type"), "password");
await input.fill(secondKey);
await assertPendingLoad(cliBinary, cliEnv, secondKey);
await capture(dialogue.locator(".modal"), "11-ask-at-launch-masked.png");
await dialogue.getByRole("button", { name: "OK", exact: true }).click();
});
},
},
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
await evalObsidianJson(
cliBinary,
`(async()=>{
await ${coreExpression}.services.setting.saveSettingData();
return JSON.stringify(true);
})()`,
session.cliEnv
);
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
console.log(
"Configuration encryption: ASK input was masked and the validated key was reused for an ordinary save in this launch."
);
console.log(`Configuration encryption screenshots: ${JSON.stringify(screenshots)}`);
} finally {
if (session) await session.app.stop();
await firstVault.dispose();
if (secondVault) await secondVault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exitCode = 1;
});
+1
View File
@@ -13,6 +13,7 @@ const focusedScenarios = new Set([
"document-history-nav",
"document-history-restore",
"settings-ui",
"configuration-encryption",
"review-harness",
"p2p-pane",
"vault-reflection",