Expand configuration encryption E2E coverage

This commit is contained in:
vorotamoroz
2026-10-13 09:56:04 +00:00
parent aa69212db9
commit 51808d95ed
6 changed files with 524 additions and 4 deletions
+1
View File
@@ -63,6 +63,7 @@
"test:e2e:obsidian:document-history-restore": "tsx test/e2e-obsidian/scripts/document-history-restore.ts",
"test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts",
"test:e2e:obsidian:configuration-encryption": "tsx test/e2e-obsidian/scripts/configuration-encryption.ts",
"test:e2e:obsidian:configuration-encryption-sync": "tsx test/e2e-obsidian/scripts/configuration-encryption-sync.ts",
"test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts",
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
"test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts",
+5 -2
View File
@@ -75,6 +75,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 -- configuration-encryption-sync
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
@@ -112,7 +113,7 @@ 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.
`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. It also checks changes from custom and ASK modes to Default through the settings interface, and recovery from a wrong saved device key, including cancellation, the validated replacement key, and another restart.
`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.
@@ -137,7 +138,9 @@ The Harness also measures ID generation with fixed in-memory data on desktop and
`test:e2e:obsidian:p2p-pane` starts one configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile. It proves that the command remains registered while the retired command, automatic pane, and ribbon entry without a P2P configuration are absent. For the configured P2P profiles, it verifies that the desktop ribbon is available, the current status command reaches the pane without it opening at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. The mobile session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, and requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions.
`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI and QR round trips, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, internal Metadata Doctor, and setting Markdown export in sequence. Start the local CouchDB, RustFS, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
`test:e2e:obsidian:configuration-encryption-sync` requires only the CouchDB fixture configured through `.test.env` or the process environment. It changes the configuration protection key through the real settings interface, restarts the source Vault, and sends a note with E2EE enabled. A fresh Vault receives the encrypted settings without a saved device key, cancels and retries recovery, and restarts with the validated key. Both Vaults then exchange exact note bytes through CouchDB. The scenario checks the restored connection credentials, E2EE passphrase, ID key, remote Metadata, and Chunks, and removes its temporary Vaults, profiles, and unique remote database.
`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, configuration encryption and its CouchDB synchronisation workflow, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI and QR round trips, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, internal Metadata Doctor, and setting Markdown export in sequence. Start the local CouchDB, RustFS, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
`test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, starts from configured plug-in data without the device-local compatibility marker, and verifies that the marker is initialised without a compatibility dialogue, reminder, or runtime pause. It then creates a note in real Obsidian, commits it into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the Metadata document and its Chunks.
@@ -0,0 +1,332 @@
import assert from "node:assert/strict";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
loadCouchDbConfig,
makeUniqueDatabaseName,
waitForCouchDbDocs,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
configureCouchDb,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
} 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-sync-configuration-key-a";
const secondKey = "synthetic-sync-configuration-key-b";
const dataPassphrase = "synthetic-note-encryption-passphrase";
const idKey = "cd".repeat(32);
const sourcePath = "E2E/configuration-source.md";
const replyPath = "E2E/configuration-reply.md";
const sourceContent = "# Configuration key rotation\n日本語とEnglishの内容を保持します。\n".repeat(200);
const replyContent = "# Configuration key recovery reply\n復旧した端末から送信します。\n".repeat(200);
const diagnosticsDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
async function applyKey(session: ObsidianLiveSyncSession, key: string, changeMode: boolean): Promise<void> {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
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 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 navigator.close();
});
}
async function assertConnection(
cli: string,
session: ObsidianLiveSyncSession,
expected: Record<string, unknown>
): Promise<void> {
const settings = await evalObsidianJson(
cli,
`(()=>{
const s=${coreExpression}.services.setting.currentSettings();
return JSON.stringify({uri:s.couchDB_URI,user:s.couchDB_USER,password:s.couchDB_PASSWORD,
database:s.couchDB_DBNAME,encrypt:s.encrypt,passphrase:s.passphrase,idKey:s.idDerivationKey,
store:s.configPassphraseStore,key:localStorage.getItem('ls-setting-passphrase')});
})()`,
session.cliEnv
);
assert.deepEqual(settings, expected);
}
async function createNote(cli: string, session: ObsidianLiveSyncSession, path: string, content: string): Promise<void> {
await evalObsidianJson(
cli,
`(async()=>{
if(!(await app.vault.adapter.exists('E2E'))) await app.vault.createFolder('E2E');
await app.vault.create(${JSON.stringify(path)},${JSON.stringify(content)});
return JSON.stringify(true);
})()`,
session.cliEnv
);
await waitForLocalDatabaseEntry(cli, session.cliEnv, path);
}
async function assertNote(
cli: string,
session: ObsidianLiveSyncSession,
vaultPath: string,
path: string,
content: string
): Promise<void> {
// Return only a boolean through the CLI; compare the complete Vault file below.
const matches = await evalObsidianJson<boolean>(
cli,
`(async()=>{
const path=${JSON.stringify(path)};
const expected=${JSON.stringify(content)};
const deadline=Date.now()+20000;
while(Date.now()<deadline){
const actual=await app.vault.adapter.read(path).catch(()=>null);
if(actual===expected) return JSON.stringify(true);
await new Promise(resolve=>setTimeout(resolve,250));
}
throw new Error('Timed out waiting for the received note');
})()`,
session.cliEnv
);
assert.equal(matches, true);
assert.equal(await readFile(join(vaultPath, path), "utf8"), content);
}
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 couchDb = await loadCouchDbConfig();
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "configuration-encryption");
const sourceVault = await createTemporaryVault("obsidian-configuration-sync-source-");
let receiverVault: Awaited<ReturnType<typeof createTemporaryVault>> | undefined;
let session: ObsidianLiveSyncSession | undefined;
let remoteCreated = false;
let stage = "source-startup";
const connection = {
uri: couchDb.uri,
user: couchDb.username,
password: couchDb.password,
database: dbName,
encrypt: true,
passphrase: dataPassphrase,
idKey,
store: "LOCALSTORAGE",
key: secondKey,
};
try {
await assertCouchDbReachable(couchDb);
await createCouchDbDatabase(couchDb, dbName);
remoteCreated = true;
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault: sourceVault,
pluginData: createE2eCouchDbPluginData(
{ ...couchDb, dbName },
{
doctorProcessedVersion: "1.0.35",
usePowerUserMode: true,
idDerivationVersion: 0,
}
),
localStorageEntries: createE2eObsidianDeviceLocalState(sourceVault.name),
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
stage = "source-configuration";
await configureCouchDb(
cliBinary,
session.cliEnv,
{ ...couchDb, dbName },
{
doctorProcessedVersion: "1.0.35",
usePowerUserMode: true,
encrypt: true,
passphrase: dataPassphrase,
idDerivationVersion: 1,
idDerivationKey: idKey,
}
);
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await prepareRemote(cliBinary, session.cliEnv);
stage = "source-key-rotation";
await applyKey(session, firstKey, true);
await applyKey(session, secondKey, false);
const dataPath = join(sourceVault.path, ".obsidian/plugins/obsidian-livesync/data.json");
const encryptedData = await readFile(dataPath, "utf8");
assert.equal(encryptedData.includes(dataPassphrase), false);
assert.equal(encryptedData.includes(idKey), false);
assert.equal(encryptedData.includes(couchDb.password), false);
const saved = JSON.parse(encryptedData) as Record<string, unknown>;
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: sourceVault });
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await assertConnection(cliBinary, session, connection);
stage = "source-upload-after-restart";
await createNote(cliBinary, session, sourcePath, sourceContent);
await assertNote(cliBinary, session, sourceVault.path, sourcePath, sourceContent);
const sourceEntry = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, sourcePath);
await pushLocalChanges(cliBinary, session.cliEnv);
const docs = await waitForCouchDbDocs(couchDb, dbName, (docs) => {
const ids = new Set(docs.map((doc) => doc._id));
return ids.has(sourceEntry.id) && sourceEntry.children.every((id) => ids.has(id));
});
assert.ok(sourceEntry.children.length > 0);
assert.equal(
docs.some((doc) => typeof doc.data === "string" && doc.data.includes("Configuration key rotation")),
false
);
console.log(
"Configuration encryption sync: key rotation and restart retained credentials, E2EE, and the ID key; Metadata and encrypted Chunks reached CouchDB."
);
await session.app.stop();
session = undefined;
stage = "receiver-key-recovery";
receiverVault = await createTemporaryVault("obsidian-configuration-sync-receiver-");
const receiverDataPath = join(receiverVault.path, ".obsidian/plugins/obsidian-livesync/data.json");
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault: receiverVault,
pluginData: saved,
localStorageEntries: createE2eObsidianDeviceLocalState(receiverVault.name),
lifecycle: {
afterPluginLoad: async ({ remoteDebuggingPort, cliEnv }) => {
await withObsidianPage(remoteDebuggingPort, async (page) => {
const dialogue = await waitForVisibleObsidianDialogue(page, "Configuration passphrase");
assert.equal(await dialogue.locator('input[type="password"]').getAttribute("type"), "password");
const before = await readFile(receiverDataPath, "utf8");
await dialogue.getByRole("button", { name: "Cancel", exact: true }).click();
await dialogue.waitFor({ state: "hidden" });
const pending = await evalObsidianJson(
cliBinary,
`(()=>{
const core=${coreExpression};
return JSON.stringify({databaseReady:core.services.database.isDatabaseReady(),
tabRegistered:core.modules.some(module=>!!module.settingTab)});
})()`,
cliEnv
);
assert.deepEqual(pending, { databaseReady: false, tabRegistered: false });
assert.equal(await readFile(receiverDataPath, "utf8"), before);
await evalObsidianJson(
cliBinary,
`(async()=>{
await app.plugins.disablePlugin('obsidian-livesync');
await app.plugins.enablePlugin('obsidian-livesync');
return JSON.stringify(true);
})()`,
cliEnv
);
const retry = await waitForVisibleObsidianDialogue(page, "Configuration passphrase");
await retry.locator('input[type="password"]').fill(secondKey);
await mkdir(diagnosticsDirectory, { recursive: true });
await retry.locator(".modal").screenshot({
path: join(diagnosticsDirectory, "configuration-sync-unlock.png"),
animations: "disabled",
caret: "hide",
});
await retry.getByRole("button", { name: "OK", exact: true }).click();
});
},
},
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await assertConnection(cliBinary, session, connection);
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: receiverVault });
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await assertConnection(cliBinary, session, connection);
await pushLocalChanges(cliBinary, session.cliEnv);
stage = "receiver-transfer-after-restart";
await assertNote(cliBinary, session, receiverVault.path, sourcePath, sourceContent);
await createNote(cliBinary, session, replyPath, replyContent);
await assertNote(cliBinary, session, receiverVault.path, replyPath, replyContent);
await pushLocalChanges(cliBinary, session.cliEnv);
const replyEntry = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, replyPath);
await waitForCouchDbDocs(couchDb, dbName, (docs) => docs.some((doc) => doc._id === replyEntry.id));
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: sourceVault });
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await assertConnection(cliBinary, session, connection);
await pushLocalChanges(cliBinary, session.cliEnv);
stage = "source-receive-reply";
await assertNote(cliBinary, session, sourceVault.path, replyPath, replyContent);
console.log(
"Configuration encryption sync: copied settings survived cancellation, key recovery, and restart; both real Obsidian Vaults exchanged exact note bytes through CouchDB."
);
} catch (error) {
await mkdir(diagnosticsDirectory, { recursive: true });
if (session) {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.screenshot({
path: join(diagnosticsDirectory, "configuration-sync.failure.png"),
fullPage: true,
});
}).catch(() => undefined);
const state = await evalObsidianJson(
cliBinary,
`(()=>{
const core=${coreExpression};
return JSON.stringify({databaseReady:core?.services.database.isDatabaseReady(),
appReady:core?.services.appLifecycle.isReady(),
mode:core?.settings?.configPassphraseStore});
})()`,
session.cliEnv
).catch(() => ({ stateReadFailed: true }));
await writeFile(
join(diagnosticsDirectory, "configuration-sync.failure.json"),
JSON.stringify({ stage, state }, null, 2) + "\n"
);
}
throw error;
} finally {
try {
if (session) await session.app.stop();
} finally {
try {
await sourceVault.dispose();
if (receiverVault) await receiverVault.dispose();
} finally {
if (remoteCreated) await deleteCouchDbDatabase(couchDb, dbName);
}
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exitCode = 1;
});
@@ -12,6 +12,7 @@ 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 wrongSavedKey = "synthetic-wrong-saved-configuration-key";
const diagnosticsDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
const screenshots: string[] = [];
const saveFailureMessage =
@@ -100,7 +101,10 @@ async function applyCustomKey(session: ObsidianLiveSyncSession, key: string, cha
});
}
async function assertDefaultSettings(session: ObsidianLiveSyncSession): Promise<void> {
async function assertDefaultSettings(
session: ObsidianLiveSyncSession,
screenshot = "01-default-after-restart.png"
): 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);
@@ -111,7 +115,7 @@ async function assertDefaultSettings(session: ObsidianLiveSyncSession): Promise<
.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 capture(navigator.dialogue, screenshot);
await navigator.close();
});
}
@@ -181,6 +185,58 @@ async function applyAskMode(session: ObsidianLiveSyncSession): Promise<void> {
});
}
async function applyDefaultMode(
session: ObsidianLiveSyncSession,
screenshot = "12-ask-to-default-before-restart.png"
): 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("");
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('configPassphraseStore') && core.settings.configPassphraseStore==='';
})()`);
await capture(navigator.dialogue, screenshot);
await navigator.close();
});
}
async function assertSavedConfigurationKey(cli: string, env: NodeJS.ProcessEnv, expectedKey: string): Promise<void> {
const key = await evalObsidianJson(cli, `JSON.stringify(localStorage.getItem('ls-setting-passphrase'))`, env);
assert.equal(key, expectedKey);
}
async function assertLoadedLocalStorageConfiguration(
cli: string,
env: NodeJS.ProcessEnv,
expectedKey: string
): 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),
store:core.settings.configPassphraseStore,
key:localStorage.getItem('ls-setting-passphrase')});
})()`,
env
);
assert.deepEqual(state, {
databaseReady: true,
tabRegistered: true,
store: "LOCALSTORAGE",
key: expectedKey,
});
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
@@ -188,6 +244,7 @@ async function main(): Promise<void> {
const cliBinary = cli.binary;
const firstVault = await createTemporaryVault("obsidian-livesync-configuration-source-");
let secondVault: Awaited<ReturnType<typeof createTemporaryVault>> | undefined;
let retryVault: Awaited<ReturnType<typeof createTemporaryVault>> | undefined;
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
@@ -271,6 +328,19 @@ async function main(): Promise<void> {
console.log(
"Configuration encryption: rejected empty-key Apply displayed a notice and retained the device key, saved data, and pending edit."
);
await applyDefaultMode(session, "18-custom-to-default-before-restart.png");
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
await assertSavedConfigurationKey(cliBinary, session.cliEnv, secondKey);
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 assertSavedConfigurationKey(cliBinary, session.cliEnv, secondKey);
await assertDefaultSettings(session, "19-custom-to-default-after-restart.png");
console.log(
"Configuration encryption: changing the custom key mode to Default preserved protected values without a prompt and retained the device key after restart."
);
await session.app.stop();
session = undefined;
@@ -381,11 +451,119 @@ async function main(): Promise<void> {
console.log(
"Configuration encryption: ASK input was masked and the validated key was reused for an ordinary save in this launch."
);
await applyDefaultMode(session);
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
await assertSavedConfigurationKey(cliBinary, session.cliEnv, secondKey);
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);
await assertSavedConfigurationKey(cliBinary, session.cliEnv, secondKey);
await assertDefaultSettings(session, "13-ask-to-default-after-restart.png");
console.log(
"Configuration encryption: changing ASK_AT_LAUNCH to Default through settings restored protected values without a prompt and retained the device key after restart."
);
await session.app.stop();
session = undefined;
retryVault = await createTemporaryVault("obsidian-livesync-configuration-wrong-saved-key-");
const retryDataPath = join(retryVault.path, ".obsidian/plugins/obsidian-livesync/data.json");
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault: retryVault,
pluginData: saved,
localStorageEntries: {
...createE2eObsidianDeviceLocalState(retryVault.name),
"ls-setting-passphrase": wrongSavedKey,
},
lifecycle: {
afterPluginLoad: async ({ remoteDebuggingPort, cliEnv }) => {
await withObsidianPage(remoteDebuggingPort, async (page) => {
try {
const retry = await waitForVisibleObsidianDialogue(
page,
"Configuration could not be decrypted"
);
const input = retry.locator('input[type="password"]');
assert.equal(await input.getAttribute("type"), "password");
await assertPendingLoad(cliBinary, cliEnv, wrongSavedKey);
const beforeCancellation = await readFile(retryDataPath, "utf8");
await capture(retry.locator(".modal"), "14-saved-wrong-key-retry-prompt.png");
await retry.getByRole("button", { name: "Cancel", exact: true }).click();
await retry.waitFor({ state: "hidden" });
await assertPendingLoad(cliBinary, cliEnv, wrongSavedKey);
assert.equal(await readFile(retryDataPath, "utf8"), beforeCancellation);
await evalObsidianJson(
cliBinary,
`(async()=>{
await app.plugins.disablePlugin('obsidian-livesync');
await app.plugins.enablePlugin('obsidian-livesync');
return JSON.stringify(true);
})()`,
cliEnv
);
const retryAfterCancellation = await waitForVisibleObsidianDialogue(
page,
"Configuration could not be decrypted"
);
const retryInput = retryAfterCancellation.locator('input[type="password"]');
assert.equal(await retryInput.getAttribute("type"), "password");
await assertPendingLoad(cliBinary, cliEnv, wrongSavedKey);
await retryInput.fill(secondKey);
await capture(
retryAfterCancellation.locator(".modal"),
"15-saved-wrong-key-correct-retry.png"
);
await assertPendingLoad(cliBinary, cliEnv, wrongSavedKey);
await retryAfterCancellation.getByRole("button", { name: "OK", exact: true }).click();
} catch (error) {
await mkdir(diagnosticsDirectory, { recursive: true });
await page.screenshot({
path: join(diagnosticsDirectory, "configuration-saved-key-retry.failure.png"),
fullPage: true,
});
throw error;
}
});
},
},
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
await assertLoadedLocalStorageConfiguration(cliBinary, session.cliEnv, secondKey);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
assert.equal(await page.locator('.modal-container input[type="password"]').count(), 0);
const navigator = await openLiveSyncSettings(page);
await navigator.openPage("Power users");
await capture(navigator.dialogue, "16-saved-wrong-key-unlocked-settings.png");
await navigator.close();
});
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: retryVault });
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
await assertLoadedLocalStorageConfiguration(cliBinary, session.cliEnv, secondKey);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
assert.equal(await page.locator('.modal-container input[type="password"]').count(), 0);
const navigator = await openLiveSyncSettings(page);
await navigator.openPage("Power users");
await capture(navigator.dialogue, "17-validated-key-after-restart.png");
await navigator.close();
});
console.log(
"Configuration encryption: a saved wrong key remained intact after cancellation; the correct retry restored protected values, persisted the validated key, and survived another restart."
);
console.log(`Configuration encryption screenshots: ${JSON.stringify(screenshots)}`);
} finally {
if (session) await session.app.stop();
await firstVault.dispose();
if (secondVault) await secondVault.dispose();
if (retryVault) await retryVault.dispose();
}
}
+5
View File
@@ -18,6 +18,11 @@ const testSteps: Step[] = [
{ name: "conflict dialogue policy", args: ["run", "test:e2e:obsidian:conflict-dialog-policy"] },
{ name: "revision repair", args: ["run", "test:e2e:obsidian:revision-repair"] },
{ name: "settings UI", args: ["run", "test:e2e:obsidian:settings-ui"] },
{ name: "configuration encryption", args: ["run", "test:e2e:obsidian:configuration-encryption"] },
{
name: "configuration encryption synchronisation",
args: ["run", "test:e2e:obsidian:configuration-encryption-sync"],
},
{ name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] },
{ name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] },
{ name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] },
+1
View File
@@ -14,6 +14,7 @@ const focusedScenarios = new Set([
"document-history-restore",
"settings-ui",
"configuration-encryption",
"configuration-encryption-sync",
"review-harness",
"p2p-pane",
"vault-reflection",