Files
obsidian-livesync/test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts
T

834 lines
39 KiB
TypeScript

import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { DEVICE_ID_PREFERRED, MILESTONE_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { deriveIdKey, formatIdRecoveryCode } from "@vrtmrz/livesync-commonlib/settings";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
deleteCouchDbDatabase,
fetchCouchDbDocument,
loadCouchDbConfig,
makeUniqueDatabaseName,
putCouchDbDocument,
waitForCouchDbDocs,
type CouchDbConfig,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { assertEqual, pushLocalChanges, waitForLocalDatabaseEntry } from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
acknowledgeDisabledOptionalFeatures,
captureAndStartInitialisation,
captureGuideDialogue,
confirmFastFetch,
confirmRebuild,
enterSetupURI,
finishInitialisation,
generateSetupURIFromDevice,
modalByTitle,
resumeCompatibilityReviewIfShown,
selectRadioOption,
continueWithoutRemoteSettings,
type SetupArtifact,
} from "../runner/setupUri.ts";
import { captureObsidianPage, openLiveSyncSettings, withObsidianPage } from "../runner/ui.ts";
import { dismissConfigDoctorIfShown } from "../runner/upgradeWorkflow.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "30000";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000);
const e2eePassphrase = `%${randomBytes(24).toString("base64url")}`;
const notePath = "E2E/manual-couchdb/from-first-device.md";
const noteContent = "# Manual CouchDB setup\n\nThis note was sent by the manually configured first device.\n";
const returnNotePath = "E2E/manual-couchdb/from-second-device.md";
const returnNoteContent =
"# Manual CouchDB return journey\n\nThis note returned through a Setup URI generated by the first device.\n";
const captures = {
scenario: "couchdb-manual-setup-workflow",
guide: "couchdb-manual",
} as const;
const e2eeRebuildCaptures = {
scenario: "couchdb-manual-setup-workflow",
guide: "couchdb-manual-e2ee-rebuild",
} as const;
type RunnerContext = {
binary: string;
cliBinary: string;
couchDb: CouchDbConfig;
dbName: string;
activeSessions: Set<ObsidianLiveSyncSession>;
};
async function startUnconfiguredSession(
context: RunnerContext,
vault: TemporaryVault
): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
context.activeSessions.add(session);
return session;
}
async function stopTrackedSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
if (!context.activeSessions.has(session)) return;
await session.app.stop();
context.activeSessions.delete(session);
}
async function stopTrackedSessions(context: RunnerContext): Promise<void> {
for (const session of [...context.activeSessions]) {
await stopTrackedSession(context, session);
}
}
async function captureFailure(session: ObsidianLiveSyncSession, label: string): Promise<void> {
const screenshot = await captureObsidianPage(
session.remoteDebuggingPort,
`couchdb-manual-${label}-failure.png`,
async () => undefined
).catch(() => undefined);
if (screenshot) {
console.error(`Manual CouchDB failure screenshot: ${screenshot}`);
}
}
async function enterManualCouchDBSettings(
port: number,
couchDb: CouchDbConfig,
dbName: string,
independentIdSource?: string
): Promise<string[]> {
const screenshots: string[] = [];
await withObsidianPage(port, async (page) => {
const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });
const intro = modalByTitle(page, "Welcome to Self-hosted LiveSync");
await intro.waitFor({ state: "visible", timeout: uiTimeoutMs });
await selectRadioOption(intro, "I am setting this up for the first time");
await intro
.getByRole("button", { name: "Yes, I want to set up a new synchronisation" })
.click({ timeout: uiTimeoutMs });
});
screenshots.push(
await captureGuideDialogue(port, "guide-couchdb-manual-connection-method.png", "Connection Method")
);
await withObsidianPage(port, async (page) => {
const method = modalByTitle(page, "Connection Method");
await selectRadioOption(method, "Configure a remote manually");
await method.getByRole("button", { name: "Proceed with manual configuration" }).click({ timeout: uiTimeoutMs });
const encryption = modalByTitle(page, "End-to-End Encryption");
await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs });
assertEqual(
await encryption.locator('input[name="e2ee-passphrase"]').count(),
0,
"The passphrase field was present before end-to-end encryption was enabled."
);
assertEqual(
await encryption.locator("label.row").filter({ hasText: "Obfuscate Properties" }).count(),
0,
"The Obfuscate Properties row was present before end-to-end encryption was enabled."
);
const disabledIdChoices = encryption.locator("fieldset.sls-id-choices").first();
assertEqual(
await disabledIdChoices.evaluate((element) => element.hasAttribute("disabled")),
true,
"The ID configuration was enabled while E2EE was off."
);
for (const value of ["keep", "random", "custom"]) {
assertEqual(
await disabledIdChoices.locator(`input[value="${value}"]`).isDisabled(),
true,
`The ${value} ID configuration was enabled while E2EE was off.`
);
}
const disabledIdScreenshot = join(
process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e",
"guide-couchdb-manual-id-generation-disabled.png"
);
await disabledIdChoices.screenshot({ path: disabledIdScreenshot });
screenshots.push(disabledIdScreenshot);
assertEqual(
await disabledIdChoices.evaluate((element) => Number(getComputedStyle(element).opacity) < 1),
true,
"The disabled ID configuration did not look disabled in the default theme."
);
await encryption
.locator("label.row")
.filter({ hasText: "End-to-End Encryption" })
.locator('input[type="checkbox"]')
.first()
.check({ timeout: uiTimeoutMs });
assertEqual(
await disabledIdChoices.evaluate((element) => Number(getComputedStyle(element).opacity)),
1,
"The ID configuration remained dimmed after E2EE was enabled."
);
const passphraseInput = encryption.locator('input[name="e2ee-passphrase"]');
await passphraseInput.waitFor({ state: "visible", timeout: uiTimeoutMs });
await encryption
.locator("label.row")
.filter({ hasText: "Obfuscate Properties" })
.locator('input[type="checkbox"]')
.first()
.check({ timeout: uiTimeoutMs });
await passphraseInput.fill(e2eePassphrase);
const idChoices = encryption.locator('input[type="radio"][name="id-derivation-choice"]');
assertEqual(await idChoices.count(), 3, "The three ID configurations were not all shown.");
for (const value of ["keep", "random", "custom"]) {
assertEqual(
await encryption.locator(`input[name="id-derivation-choice"][value="${value}"]`).isVisible(),
true,
`The ${value} ID configuration was not visible.`
);
}
const keepChoice = encryption.locator('input[name="id-derivation-choice"][value="keep"]');
const randomChoice = encryption.locator('input[name="id-derivation-choice"][value="random"]');
const customChoice = encryption.locator('input[name="id-derivation-choice"][value="custom"]');
assertEqual(await randomChoice.isChecked(), true, "The default ID configuration was not random.");
assertEqual(
await encryption.getByText("Keep current configuration", { exact: true }).count(),
1,
"The current-configuration choice was not labelled consistently."
);
assertEqual(
await encryption.getByText("Current configuration: no ID key is saved.", { exact: false }).count(),
1,
"The current legacy configuration was not explained."
);
await keepChoice.check({ timeout: uiTimeoutMs });
assertEqual(
await encryption.getByText("Changing the E2EE passphrase changes IDs", { exact: false }).count(),
1,
"Keeping legacy IDs did not explain the effect of changing the E2EE passphrase."
);
await randomChoice.check({ timeout: uiTimeoutMs });
if (independentIdSource) {
await customChoice.check({ timeout: uiTimeoutMs });
const customChoices = encryption.locator('input[type="radio"][name="id-custom-choice"]');
assertEqual(await customChoices.count(), 3, "The three custom ID inputs were not all shown.");
for (const value of ["passphrase", "source", "recovery"]) {
assertEqual(
await encryption.locator(`input[name="id-custom-choice"][value="${value}"]`).isVisible(),
true,
`The ${value} custom ID input was not visible.`
);
}
const sourceChoice = encryption.locator('input[name="id-custom-choice"][value="source"]');
assertEqual(await sourceChoice.isChecked(), true, "The custom ID input was not selected by default.");
await encryption
.locator('input[name="id-custom-choice"][value="passphrase"]')
.check({ timeout: uiTimeoutMs });
assertEqual(
await encryption.locator('input[name="id-derivation-source"]').count(),
0,
"The E2EE passphrase choice exposed a second source input."
);
await encryption
.locator('input[name="id-custom-choice"][value="recovery"]')
.check({ timeout: uiTimeoutMs });
assertEqual(
await encryption.locator('input[name="id-derivation-source"]').getAttribute("placeholder"),
"Enter an ID recovery code",
"The recovery-code choice did not request a recovery code."
);
const recoveryChoice = encryption.locator('input[name="id-custom-choice"][value="recovery"]');
await sourceChoice.check({ timeout: uiTimeoutMs });
const sourceInput = encryption.locator('input[name="id-derivation-source"]');
await sourceInput.fill("");
await encryption.getByRole("button", { name: "Proceed", exact: true }).click({ timeout: uiTimeoutMs });
assertEqual(
await encryption.getByText("An ID source is required to enable this option.", { exact: false }).count(),
1,
"A first-time independent ID configuration did not require a source."
);
assertEqual(
await encryption.isVisible(),
true,
"The E2EE dialogue closed after a first-time ID source was omitted."
);
await recoveryChoice.check({ timeout: uiTimeoutMs });
await sourceChoice.check({ timeout: uiTimeoutMs });
await sourceInput.fill(independentIdSource);
}
const passwordToggle = passphraseInput.locator("..").locator("button.sls-password-toggle");
await passwordToggle.click({ timeout: uiTimeoutMs });
assertEqual(
await passphraseInput.getAttribute("type"),
"text",
"Toggling visibility did not reveal the passphrase."
);
assertEqual(
await passphraseInput.inputValue(),
e2eePassphrase,
"Toggling visibility changed the passphrase value."
);
await passwordToggle.click({ timeout: uiTimeoutMs });
assertEqual(
await passphraseInput.getAttribute("type"),
"password",
"Toggling visibility again did not re-mask the passphrase."
);
assertEqual(await passphraseInput.inputValue(), e2eePassphrase, "Re-masking the passphrase changed its value.");
});
screenshots.push(await captureGuideDialogue(port, "guide-couchdb-manual-encryption.png", "End-to-End Encryption"));
await withObsidianPage(port, async (page) => {
const encryption = modalByTitle(page, "End-to-End Encryption");
await encryption.getByRole("button", { name: "Proceed", exact: true }).click({ timeout: uiTimeoutMs });
await encryption.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
screenshots.push(
await captureGuideDialogue(port, "guide-couchdb-manual-remote-selection.png", "Choose a synchronisation remote")
);
await withObsidianPage(port, async (page) => {
const remoteSelection = modalByTitle(page, "Choose a synchronisation remote");
await selectRadioOption(remoteSelection, "CouchDB");
await remoteSelection
.getByRole("button", { name: "Continue to CouchDB setup", exact: true })
.click({ timeout: uiTimeoutMs });
const couchDB = modalByTitle(page, "CouchDB Configuration");
await couchDB.waitFor({ state: "visible", timeout: uiTimeoutMs });
await couchDB.locator('input[name="couchdb-url"]').fill(couchDb.uri);
await couchDB.locator('input[name="couchdb-username"]').fill(couchDb.username);
await couchDB.locator('input[name="couchdb-password"]').fill(couchDb.password);
await couchDB.locator('input[name="couchdb-database"]').fill(dbName);
});
screenshots.push(
await captureGuideDialogue(port, "guide-couchdb-manual-connection-details.png", "CouchDB Configuration")
);
await withObsidianPage(port, async (page) => {
const couchDB = modalByTitle(page, "CouchDB Configuration");
await couchDB
.getByRole("button", { name: "Check server requirements", exact: true })
.click({ timeout: uiTimeoutMs });
const summary = couchDB.locator(".check-results summary");
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
await summary
.filter({ hasText: /All checks passed successfully!|issue\(s\) detected!/u })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
const errors = couchDB.locator(".check-result.error");
if ((await errors.count()) > 0) {
const messages = await errors.locator(".message").allTextContents();
throw new Error(`The documented CouchDB fixture failed its server requirements: ${messages.join(" | ")}`);
}
const details = couchDB.locator(".check-results details");
if (!(await details.evaluate((element) => (element as HTMLDetailsElement).open))) {
await summary.click({ timeout: uiTimeoutMs });
}
});
screenshots.push(
await captureGuideDialogue(port, "guide-couchdb-manual-server-requirements.png", "CouchDB Configuration")
);
await withObsidianPage(port, async (page) => {
const couchDB = modalByTitle(page, "CouchDB Configuration");
await couchDB
.getByRole("button", { name: "Create or connect to database and continue", exact: true })
.click({ timeout: uiTimeoutMs });
await modalByTitle(page, "Setup Complete: Preparing to Initialise Server").waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
return screenshots;
}
async function writeNoteViaObsidian(
cliBinary: string,
environment: NodeJS.ProcessEnv,
path: string,
content: string
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const content=${JSON.stringify(content)};`,
"const folder=path.split('/').slice(0,-1).join('/');",
"if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);",
"const existing=app.vault.getAbstractFileByPath(path);",
"if(existing) await app.vault.modify(existing,content);",
"else await app.vault.create(path,content);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment
);
}
async function waitForVaultFile(
vault: TemporaryVault,
path: string,
expected: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000)
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastContent = "";
while (Date.now() < deadline) {
try {
lastContent = await readFile(join(vault.path, path), "utf8");
if (lastContent === expected) return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`);
}
async function waitForRemoteEntry(context: RunnerContext, entry: { id: string; children: string[] }): Promise<void> {
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
const ids = new Set(docs.map((doc) => doc._id));
return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId));
});
}
async function assertPersistedE2EE(vault: TemporaryVault, independentIdSource?: string): Promise<void> {
const rawSettings = await readFile(
join(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json"),
"utf8"
);
const persisted = JSON.parse(rawSettings) as {
encrypt?: unknown;
encryptedPassphrase?: unknown;
passphrase?: unknown;
idDerivationVersion?: unknown;
idDerivationKey?: unknown;
encryptedIdDerivationKey?: 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.");
}
if (JSON.stringify(persisted).includes(e2eePassphrase)) {
throw new Error("Manual CouchDB setup persisted the E2EE passphrase in plain text.");
}
assertEqual(persisted.idDerivationVersion, 1, "The independent ID mode was not persisted.");
assertEqual(persisted.idDerivationKey, "", "The derived ID key was stored in plain text.");
if (typeof persisted.encryptedIdDerivationKey !== "string" || !persisted.encryptedIdDerivationKey) {
throw new Error("The derived ID key was not encrypted in local settings.");
}
if (independentIdSource) {
if (rawSettings.includes(independentIdSource)) throw new Error("The ID source was stored in local settings.");
}
}
async function assertRestoredE2EEPassphrase(session: ObsidianLiveSyncSession, cliBinary: string): Promise<void> {
const restored = await evalObsidianJson<boolean>(
cliBinary,
[
"(()=>{",
"const settings=app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings();",
`return JSON.stringify(settings.passphrase === ${JSON.stringify(e2eePassphrase)});`,
"})()",
].join(""),
session.cliEnv
);
assertEqual(restored, true, "The E2EE passphrase was not restored after Obsidian restarted.");
}
async function assertCurrentIdDerivationKey(
session: ObsidianLiveSyncSession,
cliBinary: string,
expected: string,
context: string
): Promise<void> {
const settings = await evalObsidianJson<{ idDerivationVersion: number; idDerivationKey: string }>(
cliBinary,
[
"(()=>{",
"const settings=app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings();",
"return JSON.stringify({idDerivationVersion:settings.idDerivationVersion,idDerivationKey:settings.idDerivationKey});",
"})()",
].join(""),
session.cliEnv
);
assertEqual(settings.idDerivationVersion, 1, `${context}: the independent ID version was not retained.`);
assertEqual(settings.idDerivationKey, expected, `${context}: the saved ID key changed.`);
}
async function assertRecoveryCodeCanBeRevealed(
session: ObsidianLiveSyncSession,
cliBinary: string,
source: string
): Promise<void> {
const port = session.remoteDebuggingPort;
const expected = formatIdRecoveryCode(await deriveIdKey(source));
await withObsidianPage(port, async (page) => {
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
const remotePage = await settingsNavigator.openPage("Remote Configuration");
await remotePage
.locator(".setting-item")
.filter({ hasText: "Configure E2EE" })
.getByRole("button", { name: "Configure", exact: true })
.click({ timeout: uiTimeoutMs });
const encryption = modalByTitle(page, "End-to-End Encryption");
await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs });
assertEqual(
await encryption.locator('input[name="id-derivation-choice"][value="keep"]').isChecked(),
true,
"An existing ID key was not selected for reuse."
);
assertEqual(
await encryption.getByText("Current configuration: a saved ID key is used.").count(),
1,
"The saved ID key was not explained."
);
await encryption.getByRole("button", { name: "Show current recovery code" }).click({ timeout: uiTimeoutMs });
assertEqual(
await encryption.getByRole("textbox", { name: "Current ID recovery code" }).inputValue(),
expected,
"The displayed recovery code did not contain the saved ID key."
);
await encryption.locator('input[name="id-derivation-choice"][value="custom"]').check({ timeout: uiTimeoutMs });
await encryption.locator('input[name="id-custom-choice"][value="recovery"]').check({ timeout: uiTimeoutMs });
const recoveryInput = encryption.locator('input[name="id-derivation-source"]');
await recoveryInput.fill("not-a-recovery-code");
await encryption.getByRole("button", { name: "Proceed" }).click({ timeout: uiTimeoutMs });
assertEqual(
await encryption.getByText("The ID source or recovery code is invalid.", { exact: false }).count(),
1,
"The recovery-code choice accepted an ordinary source string."
);
await recoveryInput.fill(expected);
await encryption.getByRole("button", { name: "Proceed" }).click({ timeout: uiTimeoutMs });
await encryption.waitFor({ state: "hidden", timeout: uiTimeoutMs });
assertEqual(
await modalByTitle(page, "Mostly Complete: Decision Required").count(),
0,
"Recovering the existing ID key opened a new setup decision."
);
});
const expectedIdKey = await deriveIdKey(source);
await assertCurrentIdDerivationKey(session, cliBinary, expectedIdKey, "Recovering the saved ID key");
await withObsidianPage(port, async (page) => {
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
const remotePage = await settingsNavigator.openPage("Remote Configuration");
await remotePage
.locator(".setting-item")
.filter({ hasText: "Configure E2EE" })
.getByRole("button", { name: "Configure", exact: true })
.click({ timeout: uiTimeoutMs });
const encryption = modalByTitle(page, "End-to-End Encryption");
await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs });
await encryption.locator('input[name="id-derivation-choice"][value="custom"]').check({ timeout: uiTimeoutMs });
await encryption.locator('input[name="id-custom-choice"][value="source"]').check({ timeout: uiTimeoutMs });
const sourceInput = encryption.locator('input[name="id-derivation-source"]');
await sourceInput.fill("");
assertEqual(await sourceInput.inputValue(), "", "The independent ID source input was not empty.");
assertEqual(
await encryption.getByText("Leave this input empty to keep the saved ID key.", { exact: true }).count(),
1,
"The configured ID source did not explain that an empty input keeps the saved ID key."
);
await encryption.getByRole("button", { name: "Proceed", exact: true }).click({ timeout: uiTimeoutMs });
await encryption.waitFor({ state: "hidden", timeout: uiTimeoutMs });
assertEqual(
await modalByTitle(page, "Mostly Complete: Decision Required").count(),
0,
"Keeping the saved ID key after an empty source opened a new setup decision."
);
});
await assertCurrentIdDerivationKey(session, cliBinary, expectedIdKey, "Saving an empty custom ID source");
}
async function readDocumentId(cliBinary: string, environment: NodeJS.ProcessEnv, path: string): Promise<string> {
return await evalObsidianJson<string>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"return JSON.stringify(await core.services.path.path2id(path));",
"})()",
].join(""),
environment
);
}
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 dismissConfigDoctorIfShown(port);
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> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) {
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
}
const couchDb = await loadCouchDbConfig();
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "manual-setup");
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const context: RunnerContext = {
binary,
cliBinary: cli.binary,
couchDb,
dbName,
activeSessions: new Set(),
};
const screenshots: string[] = [];
let secondDeviceArtifact: SetupArtifact | undefined;
const independentIdSource =
process.env.E2E_OBSIDIAN_INDEPENDENT_IDS === "true" ? randomBytes(32).toString("base64url") : undefined;
let firstEntryId: string | undefined;
let returnEntryId: string | undefined;
try {
await assertCouchDbReachable(couchDb);
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary Vault A: ${vaultA.path}`);
console.log(`Temporary Vault B: ${vaultB.path}`);
console.log(`CouchDB database to be created by the onboarding dialogue: ${dbName}`);
let session = await startUnconfiguredSession(context, vaultA);
try {
screenshots.push(
...(await enterManualCouchDBSettings(session.remoteDebuggingPort, couchDb, dbName, independentIdSource))
);
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new", captures));
screenshots.push(await confirmRebuild(session.remoteDebuggingPort, captures));
screenshots.push(await continueWithoutRemoteSettings(session.remoteDebuggingPort, captures));
screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, captures));
const state = await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
assertEqual(state.activeConfigurationId !== "", true, "Manual CouchDB setup did not activate a profile.");
assertEqual(
state.remoteConfigurationCount,
1,
"Manual CouchDB setup did not persist exactly one remote profile."
);
await assertPersistedE2EE(vaultA, independentIdSource);
if (independentIdSource) {
await assertRecoveryCodeCanBeRevealed(session, context.cliBinary, independentIdSource);
}
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent);
const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
firstEntryId = entry.id;
await pushLocalChanges(context.cliBinary, session.cliEnv);
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 assertRestoredE2EEPassphrase(session, context.cliBinary);
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, independentIdSource);
const rebuiltEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
if (independentIdSource) {
assertEqual(rebuiltEntry.id, firstEntryId, "Rebuild changed the configured document ID.");
}
await waitForRemoteEntry(context, rebuiltEntry);
await assertRemoteEntryEncrypted(context, rebuiltEntry, notePath, noteContent);
await assertRemotePreferredE2EE(context, true);
const generated = await generateSetupURIFromDevice(
session.remoteDebuggingPort,
randomBytes(24).toString("base64url"),
captures
);
secondDeviceArtifact = generated.artifact;
screenshots.push(...generated.screenshots);
} catch (error) {
await captureFailure(session, "e2ee-rebuild");
throw error;
} finally {
await stopTrackedSession(context, session);
}
session = await startUnconfiguredSession(context, vaultB);
try {
if (!secondDeviceArtifact) {
throw new Error("The manually configured first device did not generate a Setup URI.");
}
screenshots.push(
await enterSetupURI(session.remoteDebuggingPort, "existing", secondDeviceArtifact, captures)
);
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "existing", captures));
screenshots.push(...(await confirmFastFetch(session.remoteDebuggingPort, captures)));
await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await assertPersistedE2EE(vaultB, independentIdSource);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForVaultFile(vaultB, notePath, noteContent);
if (independentIdSource) {
assertEqual(
await readDocumentId(context.cliBinary, session.cliEnv, notePath),
firstEntryId,
"The Setup URI did not restore the document ID key on the second device."
);
}
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, returnNotePath, returnNoteContent);
const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, returnNotePath);
returnEntryId = returnEntry.id;
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForRemoteEntry(context, returnEntry);
} catch (error) {
await captureFailure(session, "second-device");
throw error;
} finally {
await stopTrackedSession(context, session);
}
session = await startUnconfiguredSession(context, vaultA);
try {
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForVaultFile(vaultA, returnNotePath, returnNoteContent);
if (independentIdSource) {
assertEqual(
await readDocumentId(context.cliBinary, session.cliEnv, returnNotePath),
returnEntryId,
"The first device did not retain the shared document ID key."
);
}
} catch (error) {
await captureFailure(session, "return-journey");
throw error;
} finally {
await stopTrackedSession(context, session);
}
console.log(
`Manual CouchDB onboarding created and tested its database, generated a second-device Setup URI, and completed a bidirectional note round-trip. Screenshots: ${screenshots.join(", ")}`
);
} finally {
await stopTrackedSessions(context).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
await vaultA.dispose();
await vaultB.dispose();
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});