mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-15 05:42:31 +00:00
Preserve device E2EE settings during remote rebuild
This commit is contained in:
@@ -291,6 +291,15 @@ export async function adjustSettingToRemote(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (operation === "rebuild") {
|
||||||
|
// An overwrite makes this device authoritative for both the Vault contents and the
|
||||||
|
// shared synchronisation settings. The remote lookup above remains a connection
|
||||||
|
// preflight, but settings from the database which is about to be replaced must not
|
||||||
|
// overwrite intentional local changes such as enabling E2EE.
|
||||||
|
log("Rebuild will use this device's synchronisation settings.", LOG_LEVEL_NOTICE);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const remoteTweaks = remoteResult.values;
|
const remoteTweaks = remoteResult.values;
|
||||||
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
|
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
|
||||||
// Check if any necessary tweak value is different from current config.
|
// Check if any necessary tweak value is different from current config.
|
||||||
|
|||||||
@@ -1149,6 +1149,33 @@ describe("Red Flag Feature", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Remote configuration adjustment", () => {
|
describe("Remote configuration adjustment", () => {
|
||||||
|
it("keeps this device's E2EE settings when preparing to overwrite the remote", async () => {
|
||||||
|
const host = createHostMock();
|
||||||
|
Object.assign(host.mocks.setting.settings, TweakValuesShouldMatchedTemplate, {
|
||||||
|
encrypt: true,
|
||||||
|
passphrase: "local-encryption-passphrase",
|
||||||
|
});
|
||||||
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
||||||
|
availableRemoteTweaks({
|
||||||
|
...TweakValuesShouldMatchedTemplate,
|
||||||
|
encrypt: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await adjustSettingToRemote(
|
||||||
|
host as any,
|
||||||
|
createLoggerMock(),
|
||||||
|
host.mocks.setting.currentSettings(),
|
||||||
|
"rebuild"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledOnce();
|
||||||
|
expect(host.mocks.setting.currentSettings().encrypt).toBe(true);
|
||||||
|
expect(host.mocks.setting.currentSettings().passphrase).toBe("local-encryption-passphrase");
|
||||||
|
expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("should skip remote configuration fetch when preventFetchingConfig is true", async () => {
|
it("should skip remote configuration fetch when preventFetchingConfig is true", async () => {
|
||||||
const host = createHostMock();
|
const host = createHostMock();
|
||||||
const config = { preventFetchingConfig: true } as any;
|
const config = { preventFetchingConfig: true } as any;
|
||||||
@@ -1855,8 +1882,15 @@ describe("Red Flag Feature", () => {
|
|||||||
it("should handle rebuildAll flag with flagHandlerToEventHandler", async () => {
|
it("should handle rebuildAll flag with flagHandlerToEventHandler", async () => {
|
||||||
const host = createHostMock();
|
const host = createHostMock();
|
||||||
const log = createLoggerMock();
|
const log = createLoggerMock();
|
||||||
|
Object.assign(host.mocks.setting.settings, TweakValuesShouldMatchedTemplate, {
|
||||||
|
encrypt: true,
|
||||||
|
passphrase: "local-encryption-passphrase",
|
||||||
|
});
|
||||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
||||||
availableRemoteTweaks({ customChunkSize: 1 })
|
availableRemoteTweaks({
|
||||||
|
...TweakValuesShouldMatchedTemplate,
|
||||||
|
encrypt: false,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
|
||||||
@@ -1868,6 +1902,8 @@ describe("Red Flag Feature", () => {
|
|||||||
await Promise.resolve(eventHandler());
|
await Promise.resolve(eventHandler());
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
expect(host.mocks.rebuilder.$rebuildEverything).toHaveBeenCalled();
|
expect(host.mocks.rebuilder.$rebuildEverything).toHaveBeenCalled();
|
||||||
|
expect(host.mocks.setting.currentSettings().encrypt).toBe(true);
|
||||||
|
expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalled();
|
expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { DEVICE_ID_PREFERRED, MILESTONE_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { evalObsidianJson } from "../runner/cli.ts";
|
import { evalObsidianJson } from "../runner/cli.ts";
|
||||||
import {
|
import {
|
||||||
assertCouchDbReachable,
|
assertCouchDbReachable,
|
||||||
deleteCouchDbDatabase,
|
deleteCouchDbDatabase,
|
||||||
|
fetchCouchDbDocument,
|
||||||
loadCouchDbConfig,
|
loadCouchDbConfig,
|
||||||
makeUniqueDatabaseName,
|
makeUniqueDatabaseName,
|
||||||
|
putCouchDbDocument,
|
||||||
waitForCouchDbDocs,
|
waitForCouchDbDocs,
|
||||||
type CouchDbConfig,
|
type CouchDbConfig,
|
||||||
} from "../runner/couchdb.ts";
|
} from "../runner/couchdb.ts";
|
||||||
@@ -28,7 +31,7 @@ import {
|
|||||||
continueWithoutRemoteSettings,
|
continueWithoutRemoteSettings,
|
||||||
type SetupArtifact,
|
type SetupArtifact,
|
||||||
} from "../runner/setupUri.ts";
|
} from "../runner/setupUri.ts";
|
||||||
import { captureObsidianPage, withObsidianPage } from "../runner/ui.ts";
|
import { captureObsidianPage, openLiveSyncSettings, withObsidianPage } from "../runner/ui.ts";
|
||||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||||
|
|
||||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
|
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
|
||||||
@@ -44,6 +47,10 @@ const captures = {
|
|||||||
scenario: "couchdb-manual-setup-workflow",
|
scenario: "couchdb-manual-setup-workflow",
|
||||||
guide: "couchdb-manual",
|
guide: "couchdb-manual",
|
||||||
} as const;
|
} as const;
|
||||||
|
const e2eeRebuildCaptures = {
|
||||||
|
scenario: "couchdb-manual-setup-workflow",
|
||||||
|
guide: "couchdb-manual-e2ee-rebuild",
|
||||||
|
} as const;
|
||||||
|
|
||||||
type RunnerContext = {
|
type RunnerContext = {
|
||||||
binary: string;
|
binary: string;
|
||||||
@@ -111,9 +118,7 @@ async function enterManualCouchDBSettings(port: number, couchDb: CouchDbConfig,
|
|||||||
await withObsidianPage(port, async (page) => {
|
await withObsidianPage(port, async (page) => {
|
||||||
const method = modalByTitle(page, "Connection Method");
|
const method = modalByTitle(page, "Connection Method");
|
||||||
await selectRadioOption(method, "Configure a remote manually");
|
await selectRadioOption(method, "Configure a remote manually");
|
||||||
await method
|
await method.getByRole("button", { name: "Proceed with manual configuration" }).click({ timeout: uiTimeoutMs });
|
||||||
.getByRole("button", { name: "Proceed with manual configuration" })
|
|
||||||
.click({ timeout: uiTimeoutMs });
|
|
||||||
|
|
||||||
const encryption = modalByTitle(page, "End-to-End Encryption");
|
const encryption = modalByTitle(page, "End-to-End Encryption");
|
||||||
await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
@@ -246,6 +251,111 @@ async function waitForRemoteEntry(context: RunnerContext, entry: { id: string; c
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertPersistedE2EE(vault: TemporaryVault): Promise<void> {
|
||||||
|
const persisted = JSON.parse(
|
||||||
|
await readFile(join(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json"), "utf8")
|
||||||
|
) as {
|
||||||
|
encrypt?: unknown;
|
||||||
|
encryptedPassphrase?: unknown;
|
||||||
|
passphrase?: unknown;
|
||||||
|
};
|
||||||
|
assertEqual(persisted.encrypt, true, "Manual CouchDB setup did not persist E2EE as enabled.");
|
||||||
|
assertEqual(persisted.passphrase, "", "Manual CouchDB setup persisted the E2EE passphrase in plain text.");
|
||||||
|
if (typeof persisted.encryptedPassphrase !== "string" || persisted.encryptedPassphrase.length === 0) {
|
||||||
|
throw new Error("Manual CouchDB setup did not persist an encrypted E2EE passphrase.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setRemotePreferredE2EEDisabled(context: RunnerContext): Promise<void> {
|
||||||
|
const milestone = await fetchCouchDbDocument(context.couchDb, context.dbName, MILESTONE_DOCID);
|
||||||
|
const tweakValues = milestone.tweak_values;
|
||||||
|
if (typeof tweakValues !== "object" || tweakValues === null || Array.isArray(tweakValues)) {
|
||||||
|
throw new Error("The existing CouchDB milestone did not contain synchronisation settings.");
|
||||||
|
}
|
||||||
|
const preferred = (tweakValues as Record<string, unknown>)[DEVICE_ID_PREFERRED];
|
||||||
|
if (typeof preferred !== "object" || preferred === null || Array.isArray(preferred)) {
|
||||||
|
throw new Error("The existing CouchDB milestone did not contain preferred synchronisation settings.");
|
||||||
|
}
|
||||||
|
await putCouchDbDocument(context.couchDb, context.dbName, {
|
||||||
|
...milestone,
|
||||||
|
tweak_values: {
|
||||||
|
...tweakValues,
|
||||||
|
[DEVICE_ID_PREFERRED]: {
|
||||||
|
...(preferred as Record<string, unknown>),
|
||||||
|
encrypt: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertRemotePreferredE2EE(context: RunnerContext, expected: boolean): Promise<void> {
|
||||||
|
const milestone = await fetchCouchDbDocument(context.couchDb, context.dbName, MILESTONE_DOCID);
|
||||||
|
const tweakValues = milestone.tweak_values;
|
||||||
|
const preferred =
|
||||||
|
typeof tweakValues === "object" && tweakValues !== null && !Array.isArray(tweakValues)
|
||||||
|
? (tweakValues as Record<string, unknown>)[DEVICE_ID_PREFERRED]
|
||||||
|
: undefined;
|
||||||
|
const encrypt =
|
||||||
|
typeof preferred === "object" && preferred !== null && !Array.isArray(preferred)
|
||||||
|
? (preferred as Record<string, unknown>).encrypt
|
||||||
|
: undefined;
|
||||||
|
assertEqual(encrypt, expected, `The remote preferred E2EE setting was not ${expected ? "enabled" : "disabled"}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scheduleRemoteOverwrite(port: number): Promise<void> {
|
||||||
|
await withObsidianPage(port, async (page) => {
|
||||||
|
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||||
|
const maintenance = await settingsNavigator.openPage("Maintenance");
|
||||||
|
const overwrite = maintenance
|
||||||
|
.locator(".setting-item")
|
||||||
|
.filter({ hasText: "Overwrite Server Data with This Device's Files" });
|
||||||
|
await overwrite
|
||||||
|
.getByRole("button", { name: "Schedule and Restart", exact: true })
|
||||||
|
.click({ timeout: uiTimeoutMs });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertRemoteEntryEncrypted(
|
||||||
|
context: RunnerContext,
|
||||||
|
entry: { id: string; path: string; children: string[] },
|
||||||
|
plaintextPath: string,
|
||||||
|
plaintext: string
|
||||||
|
): Promise<void> {
|
||||||
|
const remoteMetadata = await fetchCouchDbDocument(context.couchDb, context.dbName, entry.id);
|
||||||
|
const serialisedMetadata = JSON.stringify(remoteMetadata);
|
||||||
|
if (
|
||||||
|
!remoteMetadata._id.startsWith("f:") ||
|
||||||
|
typeof remoteMetadata.path !== "string" ||
|
||||||
|
!remoteMetadata.path.startsWith("/\\:") ||
|
||||||
|
remoteMetadata.path === entry.path ||
|
||||||
|
serialisedMetadata.includes(plaintextPath) ||
|
||||||
|
!Array.isArray(remoteMetadata.children) ||
|
||||||
|
remoteMetadata.children.length !== 0 ||
|
||||||
|
remoteMetadata.mtime !== 0 ||
|
||||||
|
remoteMetadata.ctime !== 0 ||
|
||||||
|
remoteMetadata.size !== 0
|
||||||
|
) {
|
||||||
|
throw new Error("The directly fetched CouchDB Metadata document did not protect its properties.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const childId = entry.children[0];
|
||||||
|
if (!childId) {
|
||||||
|
throw new Error("The local E2EE test entry did not reference a Chunk document.");
|
||||||
|
}
|
||||||
|
if (!childId.startsWith("h:+")) {
|
||||||
|
throw new Error(`The E2EE test entry used an unencrypted Chunk identifier: ${childId}`);
|
||||||
|
}
|
||||||
|
const remoteChunk = await fetchCouchDbDocument(context.couchDb, context.dbName, childId);
|
||||||
|
assertEqual(remoteChunk.e_, true, "The directly fetched CouchDB Chunk was not marked as encrypted.");
|
||||||
|
if (
|
||||||
|
typeof remoteChunk.data !== "string" ||
|
||||||
|
remoteChunk.data === plaintext ||
|
||||||
|
remoteChunk.data.includes(plaintext)
|
||||||
|
) {
|
||||||
|
throw new Error("The directly fetched CouchDB Chunk contained readable Vault content.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const binary = requireObsidianBinary();
|
const binary = requireObsidianBinary();
|
||||||
const cli = discoverObsidianCli();
|
const cli = discoverObsidianCli();
|
||||||
@@ -288,11 +398,37 @@ async function main(): Promise<void> {
|
|||||||
1,
|
1,
|
||||||
"Manual CouchDB setup did not persist exactly one remote profile."
|
"Manual CouchDB setup did not persist exactly one remote profile."
|
||||||
);
|
);
|
||||||
|
await assertPersistedE2EE(vaultA);
|
||||||
|
|
||||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent);
|
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent);
|
||||||
const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
|
const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
|
||||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||||
await waitForRemoteEntry(context, entry);
|
await waitForRemoteEntry(context, entry);
|
||||||
|
} catch (error) {
|
||||||
|
await captureFailure(session, "first-device");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await stopTrackedSession(context, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setRemotePreferredE2EEDisabled(context);
|
||||||
|
await assertRemotePreferredE2EE(context, false);
|
||||||
|
|
||||||
|
session = await startUnconfiguredSession(context, vaultA);
|
||||||
|
try {
|
||||||
|
await scheduleRemoteOverwrite(session.remoteDebuggingPort);
|
||||||
|
screenshots.push(await confirmRebuild(session.remoteDebuggingPort, e2eeRebuildCaptures));
|
||||||
|
screenshots.push(
|
||||||
|
await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, e2eeRebuildCaptures)
|
||||||
|
);
|
||||||
|
await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
|
||||||
|
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
|
||||||
|
await assertPersistedE2EE(vaultA);
|
||||||
|
|
||||||
|
const rebuiltEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
|
||||||
|
await waitForRemoteEntry(context, rebuiltEntry);
|
||||||
|
await assertRemoteEntryEncrypted(context, rebuiltEntry, notePath, noteContent);
|
||||||
|
await assertRemotePreferredE2EE(context, true);
|
||||||
|
|
||||||
const generated = await generateSetupURIFromDevice(
|
const generated = await generateSetupURIFromDevice(
|
||||||
session.remoteDebuggingPort,
|
session.remoteDebuggingPort,
|
||||||
@@ -302,7 +438,7 @@ async function main(): Promise<void> {
|
|||||||
secondDeviceArtifact = generated.artifact;
|
secondDeviceArtifact = generated.artifact;
|
||||||
screenshots.push(...generated.screenshots);
|
screenshots.push(...generated.screenshots);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await captureFailure(session, "first-device");
|
await captureFailure(session, "e2ee-rebuild");
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await stopTrackedSession(context, session);
|
await stopTrackedSession(context, session);
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Synchronisation and storage
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- **Overwrite Server Data with This Device's Files** now keeps this device's synchronisation settings instead of reapplying settings from the remote database which is about to be replaced. Enabling E2EE before a rebuild therefore remains enabled and uploads encrypted data. (#1146)
|
||||||
|
|
||||||
## 1.0.21
|
## 1.0.21
|
||||||
|
|
||||||
26th August, 2026
|
26th August, 2026
|
||||||
|
|||||||
Reference in New Issue
Block a user