Merge main and verify ID keys through Setup URI and QR

This commit is contained in:
vorotamoroz
2026-09-29 14:28:51 +00:00
51 changed files with 2823 additions and 181 deletions
+32 -2
View File
@@ -129,6 +129,8 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe
`test:e2e:obsidian:review-harness` exercises only the boundaries owned by the opt-in maintainer Harness. It retains a real compatibility pause, uses the fixed Harness restart action to persist a device-local continuation and reload Obsidian, and requires the Harness to delete that state before reopening. It also runs the bounded settings-lifecycle observation, confirms the dedicated Vault fixture root is removed, captures the copied privacy-bounded Markdown report, and checks the Harness layout and touch targets in mobile test mode. Compatibility explanation and persistence details remain owned by `settings-ui`, real P2P transfer remains owned by the dedicated P2P suites, and general Vault reflection remains owned by `vault-reflection`; the Harness test does not duplicate those workflows.
The Harness also measures ID generation with fixed in-memory data on desktop and mobile displays, checks that live settings remain unchanged, and verifies that the copied report includes both per-1,000-ID and per-ID timings, key derivation, and JavaScript heap availability. Mobile test mode verifies the UI and execution path; measure native device performance by running the same Harness on that device.
`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.
@@ -139,10 +141,16 @@ The same workflow checks the two remote-activity status boundaries. It first hol
`test:e2e:obsidian:couchdb-manual-setup-workflow` follows the visible onboarding path for the first device when no Setup URI is available. It enters end-to-end encryption and CouchDB details, runs the read-only `Check server requirements` step, requires the prepared fixture to pass without applying a server fix, and lets the onboarding connection test create the named database. After Rebuild completes on the first device, it creates an ordinary note, asks that working device to generate a Setup URI for a second device, completes Fetch there, and verifies a bidirectional note round-trip. The workflow captures each decision point and the expanded server-check result; password controls remain visually masked. It uses an E2EE passphrase beginning with `%`, confirms that the saved settings do not contain it in plain text, and checks that Obsidian restores it after restarting with the first Vault.
The ordinary workflow now checks that all three ID-configuration radio choices are visible, disabled and dimmed while E2EE is off, and fully visible when it is enabled. It also checks that the random key is selected by default for a new Vault, **Keep current configuration** shows its legacy explanation, and the saved key is encrypted locally and transferred by Setup URI. A screenshot of the disabled group is saved as `guide-couchdb-manual-id-generation-disabled.png`. Set `E2E_OBSIDIAN_INDEPENDENT_IDS=true` for the same visible workflow with an explicitly entered, randomly generated source. That variant checks all three nested radio choices, requires a source when no key is saved, retains the saved key when a custom source is empty, rejects an ordinary string in the recovery-code input, restores the same key from a tagged code, verifies that the source is absent from local settings, and checks that both devices compute the same obfuscated document IDs after Setup URI import and Fast Fetch.
If this status workflow fails while Obsidian is running, it writes a full-page screenshot and a JSON snapshot of the status text and counters under `/tmp/obsidian-livesync-e2e`. The dialogue-mount workflow leaves desktop and mobile screenshots for both representative Svelte routes, the Hidden File Sync workflow captures the successfully displayed JSON Resolve dialogue before selecting an option, and the Security Seed reconnect workflow captures each significant application state. The suite therefore records representative evidence without capturing every interaction. Set `E2E_OBSIDIAN_DIAGNOSTICS_DIR` to use another directory.
The two-Vault workflow verifies that each isolated Vault initialises its missing marker without a compatibility pause. Later process launches reuse the same profile-backed acknowledgement. The Hidden File Sync scenario is narrower: it starts from an explicitly acknowledged marker because it tests consumer-owned hidden-file behaviour, JSON resolution, target filtering, and grouped mobile Notices rather than duplicating the compatibility workflow. After `app.emulateMobile(true)`, its fixture operations use the active DevTools renderer because Obsidian can remove desktop-only CLI commands in mobile mode.
The two-Vault workflow also covers independent ID derivation with two real Obsidian sessions: a note travels in each direction, both devices retain the same obfuscated document IDs, and identical content reuses the same Chunk IDs. Fresh devices with a different ID key or legacy ID configuration must be rejected by ordinary CouchDB replication before any remote document or checkpoint changes. Set `E2E_OBSIDIAN_ONLY_INDEPENDENT_IDS=true` to run that case without the other two-Vault scenarios.
Set `E2E_OBSIDIAN_ONLY_DIFFERENT_CHUNK_ID_KEYS=true` to run the focused case where two devices use different saved ID keys with Path Obfuscation off. It verifies that each device can read the other's note, visible document IDs agree, and writing the same content produces different Chunk IDs.
`test:e2e:obsidian:cli-to-obsidian-sync` is the cross-runtime compatibility check for the official LiveSync CLI and the real Obsidian plug-in. Build the plug-in first, and build the local CLI too when no external CLI command is selected. The script uses E2EE, Path Obfuscation, and the current preferred chunk settings to create and synchronise a note through the CLI, starts real Obsidian with an isolated Vault and profile, synchronises the same CouchDB database, and verifies that the plug-in materialises identical note content. This covers the boundary that CLI-only and plug-in-only round trips do not exercise.
The isolated Obsidian session starts with its CouchDB settings and device-local compatibility acknowledgement already in place. This keeps the scenario focused on cross-runtime data compatibility; unconfigured start-up and visible CouchDB onboarding are covered by their dedicated workflows.
@@ -166,10 +174,15 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service and starts with isolated Object Storage settings and the device-local compatibility acknowledgement already in place, keeping the scenario focused on upload rather than unconfigured start-up or setup. It confirms those settings through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance.
Set `E2E_OBSIDIAN_INDEPENDENT_IDS=true` to run the same upload with E2EE, Path Obfuscation, and a separately derived ID key. The scenario verifies the local document and Chunk ID shapes before the Journal transfer.
Set `E2E_OBSIDIAN_CUSTOM_HTTP_HANDLER=true` when the local Object Storage fixture does not allow browser requests from Obsidian's renderer.
`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique Object Storage prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies the A-to-B note through explicit replication, then verifies that the B-to-A note arrives through `syncOnStart` after restarting the first device, without requesting manual replication. It captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped. The test requires the current version marker and absence of a compatibility pause after Fetch and after restarting the same Vault, without accepting a review automatically.
`test:e2e:obsidian:object-storage-qr-workflow` runs the same Object Storage round trip with QR settings on the second device. It takes the first device's settings, assigns a distinct database suffix in the QR fixture, encodes them with Commonlib's QR encoder, passes the payload to the real QR decoding entry point, and selects **Join this device** in the visible dialogue. Unlike the Setup URI, the QR payload includes a database suffix; explicitly choosing one makes the namespace change independent of Obsidian's initial defaults. Before Fetch begins, the scenario verifies that the imported namespace has its current compatibility marker without a pause. Fetch then selects the receiving device's own suffix when resetting the local database. The test verifies the marker and absence of a pause again after Fetch and after a natural restart. It covers the QR settings and setup flow, without requiring a camera or exercising operating-system URI dispatch.
`test:e2e:obsidian:object-storage-compatible-setup-uri-workflow` selects **Compatible (no time limit)** in the real generation dialogue and uses Persistent mode for the bootstrap tool. All three Object Storage sharing scenarios require the generated independent ID key to survive import and natural restarts on both devices, remain encrypted in local settings, and retain document and Chunk IDs during the bidirectional transfer. Before valid setup, they submit an incorrect passphrase and a URI generated in a past window, require the visible rejection, and verify unchanged runtime and persisted settings. Only an isolated fixture worker uses the past clock; Obsidian and the runner use real time.
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes.
`test:e2e:obsidian:p2p-connection-check` owns the browser-to-Obsidian preflight path. It serves the WebPeer production build from loopback, asks the page to generate a disposable Setup URI using the local relay, starts its browser reference peer, and applies that exact URI through visible onboarding in an isolated empty real Obsidian Vault. After the first successful WebRTC diagnostic appears, it selects the action for another device in the same room, proves that the Setup URI was not regenerated, applies it to a second isolated empty real Obsidian Vault, and requires both the successful total and the baseline number of simultaneous active connections to advance. It captures the result card without Setup URI credentials and does not claim to verify note synchronisation. Run `test:e2e:obsidian:p2p-connection-check:services` to build both production artefacts and let the scenario start and stop the Compose relay.
@@ -210,7 +223,7 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
`test:e2e:obsidian:internal-metadata-doctor` reuses the migration fixture and enables encryption through the real Config Doctor dialogues. It checks declining the consultation, skipping the recommendation with a reminder, dismissing the current Doctor version, and accepting the recommendation through **Run Doctor** after dismissal. Each choice is checked against active and persisted settings, with natural restarts of the same Vault and profile verifying reminders and retained choices. A local database sentinel, start-up flag checks, unchanged remote documents, and renderer identity checks detect an unintended automatic Rebuild, Fetch, or restart. The accepted setting then follows the two-device migration and Fast Fetch checks above. This scenario requires CouchDB and is included in `test:e2e:obsidian:local-suite`; run it separately with `npm run test:e2e:obsidian:focused -- internal-metadata-doctor` after starting the CouchDB fixture.
`test:e2e:obsidian:setting-markdown-export` enables setting Markdown export, waits for the generated Markdown file in the vault, and verifies that credentials are omitted when `writeCredentialsForSettingSync=false`.
`test:e2e:obsidian:setting-markdown-export` enables setting Markdown export, waits for the generated Markdown file in the vault, and verifies that credentials are omitted when `writeCredentialsForSettingSync=false`, including both the plaintext ID key and its encrypted local representation.
`test:e2e:obsidian:upgrade-from-stable` is the release-acceptance upgrade workflow. It installs the exact published 0.25.83 artefacts into an isolated Vault, verifies their pinned SHA-256 values, and then replaces only the plug-in artefacts with the current target while retaining the same Vault and isolated Obsidian profile. The first run downloads the old release into the ignored `_testdata/releases` cache; every later run verifies the cached bytes before use.
@@ -245,7 +258,24 @@ Or let the wrapper manage both fixtures:
npm run test:e2e:obsidian:local-suite:services
```
Useful environment variables:
### Combined setup and security regression checks
Build the current plug-in once, then run these focused scenarios sequentially with their documented fixtures:
| Coverage | Scenario or command |
| --- | --- |
| Time-bound URI, independent key, rejection, restart, and two-way Object Storage transfer | `npm run test:e2e:obsidian:object-storage-setup-uri-workflow` |
| Compatible URI with the same key and transfer checks | `npm run test:e2e:obsidian:object-storage-compatible-setup-uri-workflow` |
| QR import, changed database suffix, key persistence, and two-way transfer | `npm run test:e2e:obsidian:object-storage-qr-workflow` |
| Custom ID source, recovery code, encrypted local storage, and CouchDB Setup URI transfer | `E2E_OBSIDIAN_INDEPENDENT_IDS=true npm run test:e2e:obsidian:couchdb-manual-setup-workflow` |
| Matching IDs and rejection of incompatible document keys before remote writes | `E2E_OBSIDIAN_ONLY_INDEPENDENT_IDS=true npm run test:e2e:obsidian:two-vault-sync` |
| Doctor decline, reminder, dismissal, later acceptance, and mixed internal Metadata | `npm run test:e2e:obsidian:internal-metadata-doctor` |
The setup-tool contract suite also checks ID recovery and explicit legacy IDs in both URI modes. `dialog-mounts` covers the availability dialogue and setup choices on desktop and emulated mobile. These automated scenarios remove the need to repeat every decision path manually during BRAT acceptance.
BRAT acceptance still validates the exact published artefacts: install or update through BRAT, cold-start Obsidian, and exchange one note in each direction. On a physical mobile device, include one Setup URI or QR hand-off and check the displayed instructions, principal controls, and responsiveness. Camera capture, operating-system dispatch, native mobile performance, and the published installation path are outside this local E2E coverage; emulated mobile establishes layout and interaction only.
### Environment variables
- `OBSIDIAN_BINARY`: explicit Obsidian executable path.
- `OBSIDIAN_CLI`: explicit companion `obsidian-cli` executable path.
+51 -6
View File
@@ -73,7 +73,8 @@ export async function enterSetupURI(
port: number,
mode: "new" | "existing",
artifact: SetupArtifact,
captures: SetupCaptureNames
captures: SetupCaptureNames,
rejectedArtifacts: readonly SetupArtifact[] = []
): Promise<string> {
await withObsidianPage(port, async (page) => {
const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
@@ -101,6 +102,40 @@ export async function enterSetupURI(
const setup = modalByTitle(page, "Enter Setup URI");
await setup.waitFor({ state: "visible", timeout: uiTimeoutMs });
const settingsSnapshot = () =>
page.evaluate(async () => {
const obsidian = globalThis as typeof globalThis & {
app: {
plugins: {
plugins: Record<
string,
{
core: { services: { setting: { currentSettings(): unknown } } };
loadData(): Promise<unknown>;
}
>;
};
};
};
const plugin = obsidian.app.plugins.plugins["obsidian-livesync"];
return JSON.stringify({
current: plugin.core.services.setting.currentSettings(),
persisted: await plugin.loadData(),
});
});
const before = rejectedArtifacts.length > 0 ? await settingsSnapshot() : undefined;
for (const rejected of rejectedArtifacts) {
await setup.locator('input[placeholder^="obsidian://setuplivesync"]').fill(rejected.setupURI);
await setup.locator('input[name="password"]').fill(rejected.setupPassphrase);
await setup.getByRole("button", { name: "Test Settings and Continue" }).click({ timeout: uiTimeoutMs });
await setup.getByText($msg("Failed to parse Setup-URI."), { exact: false }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
if ((await settingsSnapshot()) !== before) {
throw new Error("A rejected Setup URI changed the receiving device's settings.");
}
}
await setup.locator('input[placeholder^="obsidian://setuplivesync"]').fill(artifact.setupURI);
await setup.locator('input[name="password"]').fill(artifact.setupPassphrase);
});
@@ -120,7 +155,8 @@ export async function enterSetupURI(
export async function generateSetupURIFromDevice(
port: number,
setupPassphrase: string,
captures: SetupCaptureNames
captures: SetupCaptureNames,
mode: "ephemeral" | "persistent" = "ephemeral"
): Promise<{ artifact: SetupArtifact; screenshots: string[] }> {
const opened = await withObsidianPage(port, async (page) => {
return await page.evaluate(
@@ -153,9 +189,15 @@ export async function generateSetupURIFromDevice(
const choice = modalByTitle(page, "Setup URI availability");
await choice.waitFor({ state: "visible", timeout: uiTimeoutMs });
await choice.getByText("Time-bound Setup URIs can be opened until", { exact: false }).waitFor({
state: "visible", timeout: uiTimeoutMs,
state: "visible",
timeout: uiTimeoutMs,
});
await choice.getByRole("button", { name: "Time-bound", exact: true }).click({ timeout: uiTimeoutMs });
await choice
.getByRole("button", {
name: mode === "ephemeral" ? "Time-bound" : "Compatible (no time limit)",
exact: true,
})
.click({ timeout: uiTimeoutMs });
});
const resultTitle = "Your Setup URI is ready to be copied";
@@ -407,10 +449,13 @@ export async function finishInitialisation(
while (Date.now() < deadline) {
const resumeVisible = await withObsidianPage(port, async (page) => {
const alignedSettingsNotice = page.locator(".modal-container").filter({
hasText: "Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!",
hasText:
"Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!",
});
if (await alignedSettingsNotice.isVisible()) {
await alignedSettingsNotice.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs });
await alignedSettingsNotice
.getByRole("button", { name: "OK", exact: true })
.click({ timeout: uiTimeoutMs });
}
return await modalByTitle(page, "Confirmation").filter({ hasText: message }).isVisible();
}).catch(() => false);
@@ -2,6 +2,7 @@ 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,
@@ -14,7 +15,12 @@ import {
type CouchDbConfig,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { assertEqual, pushLocalChanges, waitForLocalDatabaseEntry } from "../runner/liveSyncWorkflow.ts";
import {
assertEqual,
assertE2eCompatibilityUnpaused,
pushLocalChanges,
waitForLocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
acknowledgeDisabledOptionalFeatures,
@@ -26,7 +32,6 @@ import {
finishInitialisation,
generateSetupURIFromDevice,
modalByTitle,
resumeCompatibilityReviewIfShown,
selectRadioOption,
continueWithoutRemoteSettings,
type SetupArtifact,
@@ -99,7 +104,12 @@ async function captureFailure(session: ObsidianLiveSyncSession, label: string):
}
}
async function enterManualCouchDBSettings(port: number, couchDb: CouchDbConfig, dbName: string): Promise<string[]> {
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" });
@@ -134,12 +144,41 @@ async function enterManualCouchDBSettings(port: number, couchDb: CouchDbConfig,
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
@@ -149,7 +188,85 @@ async function enterManualCouchDBSettings(port: number, couchDb: CouchDbConfig,
.first()
.check({ timeout: uiTimeoutMs });
await passphraseInput.fill(e2eePassphrase);
const passwordToggle = encryption.locator("button.sls-password-toggle");
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"),
@@ -167,16 +284,13 @@ async function enterManualCouchDBSettings(port: number, couchDb: CouchDbConfig,
"password",
"Toggling visibility again did not re-mask the passphrase."
);
assertEqual(
await passphraseInput.inputValue(),
e2eePassphrase,
"Re-masking the passphrase changed its value."
);
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(
@@ -288,13 +402,18 @@ 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 {
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.");
@@ -304,6 +423,14 @@ async function assertPersistedE2EE(vault: TemporaryVault): Promise<void> {
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> {
@@ -320,6 +447,126 @@ async function assertRestoredE2EEPassphrase(session: ObsidianLiveSyncSession, cl
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;
@@ -430,6 +677,10 @@ async function main(): Promise<void> {
};
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);
@@ -440,23 +691,29 @@ async function main(): Promise<void> {
let session = await startUnconfiguredSession(context, vaultA);
try {
screenshots.push(...(await enterManualCouchDBSettings(session.remoteDebuggingPort, couchDb, dbName)));
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);
await assertE2eCompatibilityUnpaused(context.cliBinary, session.cliEnv, 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);
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) {
@@ -478,10 +735,13 @@ async function main(): Promise<void> {
await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, e2eeRebuildCaptures)
);
await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await assertPersistedE2EE(vaultA);
await assertE2eCompatibilityUnpaused(context.cliBinary, session.cliEnv, 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);
@@ -511,12 +771,21 @@ async function main(): Promise<void> {
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 assertE2eCompatibilityUnpaused(context.cliBinary, session.cliEnv, 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) {
@@ -528,9 +797,16 @@ async function main(): Promise<void> {
session = await startUnconfiguredSession(context, vaultA);
try {
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await assertE2eCompatibilityUnpaused(context.cliBinary, session.cliEnv, 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;
+4
View File
@@ -35,6 +35,10 @@ const testSteps: Step[] = [
name: "Object Storage Setup URI workflow",
args: ["run", "test:e2e:obsidian:object-storage-setup-uri-workflow"],
},
{
name: "Object Storage Compatible Setup URI workflow",
args: ["run", "test:e2e:obsidian:object-storage-compatible-setup-uri-workflow"],
},
{
name: "Object Storage QR workflow",
args: ["run", "test:e2e:obsidian:object-storage-qr-workflow"],
+41 -4
View File
@@ -15,6 +15,8 @@
* Separate successes would not prove that those observations belonged to the
* same upload.
*/
import { randomBytes } from "node:crypto";
import { deriveIdKey } from "@vrtmrz/livesync-commonlib/settings";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
@@ -33,6 +35,7 @@ import {
listObjectStorageObjects,
loadObjectStorageConfig,
makeUniqueBucketPrefix,
readObjectStorageJson,
} from "../runner/objectStorage.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts";
@@ -41,6 +44,8 @@ import { REMOTE_ACTIVITY_EXPECTED_STATE, waitForRemoteActivityState } from "../r
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
const notePath = "E2E/minio-upload.md";
const useIndependentIds = process.env.E2E_OBSIDIAN_INDEPENDENT_IDS === "true";
const useCustomRequestHandler = process.env.E2E_OBSIDIAN_CUSTOM_HTTP_HANDLER === "true";
const noteContent = [
"# Object Storage upload from real Obsidian",
"",
@@ -127,10 +132,23 @@ async function main(): Promise<void> {
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
const configured = await configureObjectStorage(cli.binary, session.cliEnv, {
...objectStorage,
bucketPrefix,
});
const configured = await configureObjectStorage(
cli.binary,
session.cliEnv,
{ ...objectStorage, bucketPrefix },
{
...(useIndependentIds
? {
encrypt: true,
usePathObfuscation: true,
passphrase: randomBytes(32).toString("base64url"),
idDerivationVersion: 1,
idDerivationKey: await deriveIdKey(randomBytes(32).toString("base64url")),
}
: {}),
...(useCustomRequestHandler ? { useCustomRequestHandler: true } : {}),
}
);
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
assertEqual(configured.isConfigured, true, "Self-hosted LiveSync was not marked as configured.");
assertEqual(configured.remoteType, "MINIO", "Remote type was not Object Storage.");
@@ -145,6 +163,16 @@ async function main(): Promise<void> {
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv);
if (useIndependentIds) {
if (
!/^f:[0-9a-f]{64}$/u.test(localEntry.id) ||
localEntry.children.some((child) => !/^h:\+[0-9a-f]{64}$/u.test(child))
) {
throw new Error(
`The real Obsidian Journal upload did not use independent document and Chunk IDs (document length ${localEntry.id.length}, Chunk lengths ${localEntry.children.map((child) => child.length).join(",")}).`
);
}
}
await pushLocalChanges(cli.binary, session.cliEnv);
const activityAfterUpload = await waitForRemoteActivityState(
session.remoteDebuggingPort,
@@ -160,6 +188,15 @@ async function main(): Promise<void> {
);
const keys = await waitForObjectStorageObjects(bucketPrefix);
if (useIndependentIds) {
const milestone = await readObjectStorageJson<{ encrypted_id_derivation_proof?: string }>(
objectStorage,
`${bucketPrefix}_00000000-milestone.json`
);
if (!milestone.encrypted_id_derivation_proof) {
throw new Error("The Journal milestone did not retain an encrypted ID agreement proof.");
}
}
console.log(
`Uploaded ${localEntry.path} through Journal Sync to ${objectStorage.bucket}/${bucketPrefix} (${keys.length} object(s)); tracked requests: ${activityAfterUpload.requestCount - activityBeforeUpload.requestCount}`
@@ -3,13 +3,14 @@ import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { Worker } from "node:worker_threads";
import { encodeSettingsToQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
assertE2eCompatibilityMarker,
assertE2eCompatibilityUnpaused,
pushLocalChanges,
type ConfiguredSettings,
@@ -53,11 +54,17 @@ process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
const execFileAsync = promisify(execFile);
const useCustomRequestHandler = process.argv.includes("--custom-http-handler");
const useQRCode = process.argv.includes("--qr");
const uriMode = process.argv.includes("--compatible") ? "persistent" : "ephemeral";
const captures: SetupCaptureNames = useQRCode
? { scenario: "object-storage-qr", guide: "object-storage-qr-setup" }
: useCustomRequestHandler
? { scenario: "object-storage-custom-http-handler-setup-uri", guide: "object-storage-custom-http-handler-setup" }
: { scenario: "object-storage-setup-uri", guide: "object-storage-setup" };
: uriMode === "persistent"
? { scenario: "object-storage-compatible-uri", guide: "object-storage-compatible-setup" }
: useCustomRequestHandler
? {
scenario: "object-storage-custom-http-handler-setup-uri",
guide: "object-storage-custom-http-handler-setup",
}
: { scenario: "object-storage-setup-uri", guide: "object-storage-setup" };
const noteFromFirst = "E2E/object-storage/from-first.md";
const noteFromSecond = "E2E/object-storage/from-second.md";
const firstContent =
@@ -123,12 +130,63 @@ async function generateBootstrapSetupURI(
...(useCustomRequestHandler ? { use_custom_request_handler: "true" } : {}),
passphrase: randomBytes(24).toString("base64url"),
uri_passphrase: setupPassphrase,
uri_mode: uriMode,
});
const setupURI = output.split(/\r?\n/u).find((line) => line.startsWith("obsidian://setuplivesync?settings="));
if (!setupURI) throw new Error("The public Setup URI generator did not emit an Object Storage Setup URI.");
return { setupURI, setupPassphrase };
}
async function expiredSetupURI(settings: ObsidianLiveSyncSettings, setupPassphrase: string): Promise<SetupArtifact> {
// Only this fixture worker uses a past clock; Obsidian and the runner keep real time.
const worker = new Worker(
`
const { parentPort, workerData } = require('node:worker_threads');
Date.now = () => Date.UTC(2024, 0, 1);
import('@vrtmrz/livesync-commonlib/setup-uri').then(async ({ encodeTimeBoundSetupURI }) => {
const result = await encodeTimeBoundSetupURI(workerData.settings, workerData.setupPassphrase);
parentPort.postMessage(result.uri);
});
`,
{ eval: true, workerData: { settings, setupPassphrase } }
);
try {
const setupURI = await new Promise<string>((resolve, reject) => {
worker.once("message", resolve);
worker.once("error", reject);
worker.once("exit", (code) => reject(new Error(`The expired URI fixture exited with ${code}.`)));
});
return { setupURI, setupPassphrase };
} finally {
await worker.terminate();
}
}
async function assertIndependentIdKey(
context: RunnerContext,
session: ObsidianLiveSyncSession,
vault: TemporaryVault,
expectedKey: string
): Promise<void> {
const matches = await evalObsidianJson<boolean>(
context.cliBinary,
`(() => {
const settings = app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings();
return JSON.stringify(settings.encrypt && settings.usePathObfuscation &&
settings.idDerivationVersion === 1 && settings.idDerivationKey === ${JSON.stringify(expectedKey)});
})()`,
session.cliEnv
);
assertEqual(matches, true, "The device did not retain the shared independent ID key and Path Obfuscation.");
const raw = await readFile(join(vault.path, ".obsidian/plugins/obsidian-livesync/data.json"), "utf8");
const saved = JSON.parse(raw) as Record<string, unknown>;
assertEqual(saved.idDerivationVersion, 1, "The independent ID version was not saved.");
assertEqual(saved.idDerivationKey, "", "The ID key was saved in plain text.");
if (!saved.encryptedIdDerivationKey || raw.includes(expectedKey)) {
throw new Error("The shared ID key was not encrypted in local settings.");
}
}
async function startSession(
context: RunnerContext,
vault: TemporaryVault,
@@ -297,6 +355,18 @@ async function main(): Promise<void> {
const objectStorage = await loadObjectStorageConfig();
const bucketPrefix = makeUniqueBucketPrefix("setup-uri-workflow");
const bootstrapArtifact = await generateBootstrapSetupURI(objectStorage, bucketPrefix, useCustomRequestHandler);
const bootstrapSettings = await decodeSettingsFromSetupURI(
bootstrapArtifact.setupURI,
bootstrapArtifact.setupPassphrase
);
if (!bootstrapSettings || bootstrapSettings.idDerivationVersion !== 1 || !bootstrapSettings.idDerivationKey) {
throw new Error("The public Setup URI generator did not configure an independent ID key.");
}
const idKey = bootstrapSettings.idDerivationKey;
const rejectedArtifacts = [
{ ...bootstrapArtifact, setupPassphrase: "incorrect-setup-passphrase" },
await expiredSetupURI(bootstrapSettings as ObsidianLiveSyncSettings, bootstrapArtifact.setupPassphrase),
];
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const [portA, portB] = sessionPorts();
@@ -308,13 +378,14 @@ async function main(): Promise<void> {
console.log(`Temporary Object Storage target: ${objectStorage.bucket}/${bucketPrefix}`);
const sessionA = await startSession(context, vaultA, portA);
screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures));
screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures, rejectedArtifacts));
screenshots.push(await captureAndStartInitialisation(portA, "new", captures));
screenshots.push(await confirmRebuild(portA, captures));
screenshots.push(await continueWithoutRemoteSettings(portA, captures));
screenshots.push(await acknowledgeDisabledOptionalFeatures(portA, captures));
const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv);
await assertE2eCompatibilityUnpaused(context.cliBinary, sessionA.cliEnv, portA);
await assertIndependentIdKey(context, sessionA, vaultA, idKey);
assertEqual(
firstState.endpoint,
objectStorage.endpoint,
@@ -337,9 +408,22 @@ async function main(): Promise<void> {
);
await writeNote(context.cliBinary, sessionA.cliEnv, noteFromFirst, firstContent);
const firstEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionA.cliEnv, noteFromFirst);
if (
!/^f:[0-9a-f]{64}$/u.test(firstEntry.id) ||
firstEntry.children.length === 0 ||
firstEntry.children.some((id) => !/^h:\+[0-9a-f]{64}$/u.test(id))
) {
throw new Error("The source note did not use independent document and Chunk IDs.");
}
await pushLocalChanges(context.cliBinary, sessionA.cliEnv);
await waitForObjectStorageData(objectStorage, bucketPrefix);
const generated = await generateSetupURIFromDevice(portA, randomBytes(24).toString("base64url"), captures);
const generated = await generateSetupURIFromDevice(
portA,
randomBytes(24).toString("base64url"),
captures,
uriMode
);
if (generated.artifact.setupURI === bootstrapArtifact.setupURI) {
throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one.");
}
@@ -377,7 +461,7 @@ async function main(): Promise<void> {
await stopSession(context, sessionA);
const sessionB = await startSession(context, vaultB, portB);
const initialMarker = await assertE2eCompatibilityMarker(context.cliBinary, sessionB.cliEnv);
const initialMarker = await assertE2eCompatibilityUnpaused(context.cliBinary, sessionB.cliEnv, portB);
if (qrSettings) {
assertEqual(
initialMarker.additionalSuffix === `-${qrSettings.additionalSuffixOfDatabaseName}`,
@@ -412,6 +496,7 @@ async function main(): Promise<void> {
screenshots.push(...(await confirmFastFetch(portB, captures)));
const secondState = await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv);
const fetchedMarker = await assertE2eCompatibilityUnpaused(context.cliBinary, sessionB.cliEnv, portB);
await assertIndependentIdKey(context, sessionB, vaultB, idKey);
assertEqual(
secondState.endpoint,
objectStorage.endpoint,
@@ -429,6 +514,13 @@ async function main(): Promise<void> {
);
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
await waitForPathContent(vaultB, noteFromFirst, firstContent);
const importedEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, noteFromFirst);
assertEqual(importedEntry.id, firstEntry.id, "Import changed the obfuscated document ID.");
assertEqual(
JSON.stringify(importedEntry.children),
JSON.stringify(firstEntry.children),
"Import changed the Chunk IDs."
);
screenshots.push(
await captureNote(
portB,
@@ -439,10 +531,12 @@ async function main(): Promise<void> {
);
await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent);
const secondEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, noteFromSecond);
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
await stopSession(context, sessionB);
const returningSessionB = await startSession(context, vaultB, portB);
await waitForLiveSyncCoreReady(context.cliBinary, returningSessionB.cliEnv);
await assertIndependentIdKey(context, returningSessionB, vaultB, idKey);
const restartedMarker = await assertE2eCompatibilityUnpaused(
context.cliBinary,
returningSessionB.cliEnv,
@@ -459,10 +553,22 @@ async function main(): Promise<void> {
const returningSessionA = await startSession(context, vaultA, portA);
await waitForLiveSyncCoreReady(context.cliBinary, returningSessionA.cliEnv);
await assertE2eCompatibilityUnpaused(context.cliBinary, returningSessionA.cliEnv, portA);
await assertIndependentIdKey(context, returningSessionA, vaultA, idKey);
// Deliberately omit manual replication here. Object Storage reports
// Continuous as not applicable, so startup scheduling must honour the
// retained syncOnStart setting by running an unattended OneShot.
await waitForPathContent(vaultA, noteFromSecond, secondContent);
const returnedEntry = await waitForLocalDatabaseEntry(
context.cliBinary,
returningSessionA.cliEnv,
noteFromSecond
);
assertEqual(returnedEntry.id, secondEntry.id, "The return journey changed the obfuscated document ID.");
assertEqual(
JSON.stringify(returnedEntry.children),
JSON.stringify(secondEntry.children),
"The return journey changed the Chunk IDs."
);
screenshots.push(
await captureNote(
portA,
+112 -54
View File
@@ -167,7 +167,8 @@ async function captureReadinessFailure(
async function openHarness(): Promise<void> {
const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
return await page.evaluate(
(commandId) => (globalThis as ReviewHarnessTestGlobal).app?.commands?.executeCommandById(commandId) === true,
(commandId) =>
(globalThis as ReviewHarnessTestGlobal).app?.commands?.executeCommandById(commandId) === true,
"obsidian-livesync:open-review-harness"
);
});
@@ -206,11 +207,54 @@ async function runAutomaticScenarios(): Promise<void> {
});
}
async function runIdBenchmark(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const snapshotSettings = () =>
page.evaluate(() => {
const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"] as {
core: { services: { setting: { currentSettings(): unknown } } };
};
return JSON.stringify(plugin.core.services.setting.currentSettings());
});
const before = await snapshotSettings();
const harness = page.locator('[data-testid="review-harness"]');
await harness
.locator('[data-testid="review-harness-run-id-generation-performance"]')
.click({ timeout: uiTimeoutMs });
const result = harness.locator('[data-testid="review-harness-result-id-generation-performance"]');
await result.getByText("Passed:", { exact: false }).waitFor({ state: "visible", timeout: uiTimeoutMs * 4 });
const observations = await result.locator("li").allTextContents();
for (const label of [
"Chunk IDs, 256 B",
"Chunk IDs, 4096 B",
"Chunk IDs, 32768 B",
"Obfuscated document IDs",
]) {
for (const mode of ["legacy", "independent"]) {
if (
!observations.some(
(line) =>
line.startsWith(`${label}, ${mode}: 1000 IDs total median=`) && line.includes("; per ID=")
)
) {
throw new Error(`Missing benchmark timing and units: ${label}, ${mode}`);
}
}
}
if (
!observations.some((line) => line.startsWith("ID key derivation at save time:")) ||
!observations.some((line) => line.startsWith("JavaScript heap:"))
) {
throw new Error("The benchmark did not report derivation and heap observations.");
}
if ((await snapshotSettings()) !== before) throw new Error("The benchmark changed the live settings.");
await assertNoHorizontalOverflow(page, harness, { label: "ID benchmark results" });
});
}
async function runVaultFixture(): Promise<string> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page
.locator('[data-testid="review-harness-run-vault-round-trip"]')
.click({ timeout: uiTimeoutMs });
await page.locator('[data-testid="review-harness-run-vault-round-trip"]').click({ timeout: uiTimeoutMs });
const confirmation = page.locator(".modal-container").filter({
has: page.getByText("Review Harness: Vault fixture access", { exact: true }),
});
@@ -273,27 +317,22 @@ async function restartAndResumeHarness(): Promise<string> {
});
await keepCompatibilityPaused();
await waitForHarness();
return await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"review-harness-resumed.png",
async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness
.locator('[data-testid="review-harness-resumed"]')
.waitFor({ state: "visible", timeout: uiTimeoutMs });
const continuationRemoved = await page.evaluate((stateKey) => {
const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) {
throw new Error("Self-hosted LiveSync is unavailable after restart.");
}
const core = (plugin as { core: { services: { setting: { getSmallConfig(key: string): string } } } })
.core;
return core.services.setting.getSmallConfig(stateKey) === "";
}, REVIEW_HARNESS_STATE_KEY);
if (!continuationRemoved) throw new Error("The one-shot continuation was not removed before use.");
await assertNoHorizontalOverflow(page, harness, { label: "resumed Review Harness" });
}
);
return await captureObsidianDialogue(obsidianRemoteDebuggingPort(), "review-harness-resumed.png", async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness
.locator('[data-testid="review-harness-resumed"]')
.waitFor({ state: "visible", timeout: uiTimeoutMs });
const continuationRemoved = await page.evaluate((stateKey) => {
const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) {
throw new Error("Self-hosted LiveSync is unavailable after restart.");
}
const core = (plugin as { core: { services: { setting: { getSmallConfig(key: string): string } } } }).core;
return core.services.setting.getSmallConfig(stateKey) === "";
}, REVIEW_HARNESS_STATE_KEY);
if (!continuationRemoved) throw new Error("The one-shot continuation was not removed before use.");
await assertNoHorizontalOverflow(page, harness, { label: "resumed Review Harness" });
});
}
async function completeResumedCompatibilityStep(): Promise<void> {
@@ -332,9 +371,7 @@ async function copyAndReadReport(): Promise<string> {
undefined,
{ timeout: uiTimeoutMs }
);
return await page.evaluate(
() => (globalThis as ReviewHarnessTestGlobal).reviewHarnessCopiedReport ?? ""
);
return await page.evaluate(() => (globalThis as ReviewHarnessTestGlobal).reviewHarnessCopiedReport ?? "");
});
}
@@ -348,35 +385,36 @@ async function verifyMobileHarness(): Promise<string> {
if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) {
throw new Error("Self-hosted LiveSync is unavailable in mobile test mode.");
}
const core = (plugin as {
core: { services: { API: { showWindow(type: string): Promise<void> } } };
}).core;
const core = (
plugin as {
core: { services: { API: { showWindow(type: string): Promise<void> } } };
}
).core;
await core.services.API.showWindow(viewType);
}, "self-hosted-livesync-review-harness");
});
return await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"review-harness-mobile.png",
async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertNoHorizontalOverflow(page, harness, { label: "mobile Review Harness" });
const heading = harness.getByRole("heading", { name: "Self-hosted LiveSync review harness" });
await assertLocatorWithinSafeArea(page, heading, {
label: "mobile Review Harness heading",
safeAreaInsets: iPhoneSafeArea,
await runIdBenchmark();
return await captureObsidianDialogue(obsidianRemoteDebuggingPort(), "review-harness-mobile.png", async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness.waitFor({ state: "visible", timeout: uiTimeoutMs });
await harness.getByRole("heading", { name: "Self-hosted LiveSync review harness" }).scrollIntoViewIfNeeded();
await assertNoHorizontalOverflow(page, harness, { label: "mobile Review Harness" });
const heading = harness.getByRole("heading", { name: "Self-hosted LiveSync review harness" });
await assertLocatorWithinSafeArea(page, heading, {
label: "mobile Review Harness heading",
safeAreaInsets: iPhoneSafeArea,
});
for (const testId of [
"review-harness-run-automatic",
"review-harness-run-full",
"review-harness-copy-report",
"review-harness-run-id-generation-performance",
]) {
await assertLocatorHasMinimumTouchTarget(page, harness.locator(`[data-testid="${testId}"]`), {
label: testId,
});
for (const testId of [
"review-harness-run-automatic",
"review-harness-run-full",
"review-harness-copy-report",
]) {
await assertLocatorHasMinimumTouchTarget(page, harness.locator(`[data-testid="${testId}"]`), {
label: testId,
});
}
}
);
});
}
async function main(): Promise<void> {
@@ -392,6 +430,7 @@ async function main(): Promise<void> {
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
// Config Doctor is covered by settings-ui; this fixture exercises the Harness.
doctorProcessedVersion: DoctorRegulation.version,
settingVersion: CURRENT_SETTING_VERSION,
isConfigured: true,
@@ -442,15 +481,34 @@ async function main(): Promise<void> {
const vaultConfirmationScreenshot = await runVaultFixture();
const resumedScreenshot = await restartAndResumeHarness();
await completeResumedCompatibilityStep();
await runIdBenchmark();
const report = await copyAndReadReport();
if (!report.includes("## Self-hosted LiveSync Review Harness report")) {
throw new Error("The copied Review Harness report was not Markdown evidence.");
}
for (const forbidden of [vault.name, REVIEW_HARNESS_FIXTURE_ROOT]) {
if (report.includes(forbidden)) throw new Error(`The Review Harness report exposed local state: ${forbidden}`);
for (const expected of [
"1000 IDs total median=",
"; per ID=",
"ID key derivation at save time:",
"JavaScript heap:",
]) {
if (!report.includes(expected)) throw new Error(`Missing copied benchmark observation: ${expected}`);
}
for (const forbidden of [
vault.name,
REVIEW_HARNESS_FIXTURE_ROOT,
"ab".repeat(32),
"Self-hosted LiveSync ID benchmark passphrase",
"Self-hosted LiveSync ID benchmark source",
]) {
if (report.includes(forbidden))
throw new Error(`The Review Harness report exposed local state: ${forbidden}`);
}
const mobileScreenshot = await verifyMobileHarness();
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
await mkdir(outputDirectory, { recursive: true });
await writeFile(join(outputDirectory, "review-harness-report.md"), report, "utf8");
console.log(
`Review Harness passed one-shot, fixture, report, and mobile checks. Screenshots: ${[
initialScreenshot,
+1
View File
@@ -21,6 +21,7 @@ const focusedScenarios = new Set([
"cli-to-obsidian-sync",
"minio-upload",
"object-storage-setup-uri-workflow",
"object-storage-compatible-setup-uri-workflow",
"object-storage-qr-workflow",
"object-storage-custom-http-handler-setup-uri-workflow",
"p2p-setup-uri-workflow",
@@ -1,5 +1,6 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { deriveIdKey } from "@vrtmrz/livesync-commonlib/settings";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { assertEqual } from "../runner/liveSyncWorkflow.ts";
@@ -34,7 +35,11 @@ async function waitForFileContaining(
throw new Error(`Timed out waiting for setting Markdown: ${fullPath}\nLast error: ${String(lastError)}`);
}
async function configureSettingMarkdown(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
async function configureSettingMarkdown(
cliBinary: string,
env: NodeJS.ProcessEnv,
idDerivationKey: string
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
@@ -46,6 +51,8 @@ async function configureSettingMarkdown(cliBinary: string, env: NodeJS.ProcessEn
"couchDB_USER:'e2e-user',",
"couchDB_PASSWORD:'e2e-password',",
"passphrase:'e2e-passphrase',",
"idDerivationVersion:1,",
`idDerivationKey:${JSON.stringify(idDerivationKey)},`,
"showVerboseLog:true,",
"},true);",
"await core.services.setting.saveSettingData();",
@@ -64,6 +71,7 @@ async function main(): Promise<void> {
}
const vault = await createTemporaryVault();
const idDerivationKey = await deriveIdKey("setting-markdown-export-independent-id-key-fixture");
let session: ObsidianLiveSyncSession | undefined;
try {
console.log(`Using Obsidian executable: ${binary}`);
@@ -77,19 +85,39 @@ async function main(): Promise<void> {
});
// The export is available while an unconfigured Vault remains outside
// application readiness; the session helper has already loaded the plug-in.
await configureSettingMarkdown(cli.binary, session.cliEnv);
await configureSettingMarkdown(cli.binary, session.cliEnv, idDerivationKey);
const content = await waitForFileContaining(vault.path, settingPath, [
(value) => value.includes("````yaml:livesync-setting"),
(value) => value.includes(`settingSyncFile: ${settingPath}`),
(value) => value.includes("showVerboseLog: true"),
]);
const persisted = JSON.parse(
await readFile(join(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json"), "utf-8")
) as {
idDerivationVersion?: unknown;
idDerivationKey?: unknown;
encryptedIdDerivationKey?: unknown;
};
assertEqual(persisted.idDerivationVersion, 1, "The independent ID key fixture was not persisted.");
assertEqual(persisted.idDerivationKey, "", "The independent ID key was stored in plain text locally.");
const encryptedIdDerivationKey = persisted.encryptedIdDerivationKey;
if (typeof encryptedIdDerivationKey !== "string" || encryptedIdDerivationKey.length === 0) {
throw new Error("The independent ID key fixture was not saved in encrypted local settings.");
}
assertEqual(
content.includes("couchDB_PASSWORD: e2e-password"),
false,
"Credential leaked into setting Markdown."
);
assertEqual(content.includes("passphrase: e2e-passphrase"), false, "Passphrase leaked into setting Markdown.");
assertEqual(content.includes(idDerivationKey), false, "Plaintext ID key leaked into setting Markdown.");
assertEqual(
content.includes(encryptedIdDerivationKey),
false,
"Encrypted ID key leaked into setting Markdown."
);
console.log(`Generated setting Markdown without credentials: ${settingPath}`);
} finally {
+232 -21
View File
@@ -1,11 +1,16 @@
import { mkdir, readFile, rename as renameFilesystemPath, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { SALT_OF_PASSPHRASE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { encryptString } from "@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption";
import { deriveIdKey } from "@vrtmrz/livesync-commonlib/settings";
import { CENTRAL_COMPATIBILITY_REJECTION_REASONS } from "@vrtmrz/livesync-commonlib/replication";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
fetchAllCouchDbDocs,
fetchCouchDbLocalDocs,
loadCouchDbConfig,
makeUniqueDatabaseName,
waitForCouchDbDocs,
@@ -18,6 +23,7 @@ import {
assertE2eCompatibilityUnpaused,
configureCouchDb,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
@@ -439,7 +445,8 @@ async function renameNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv,
async function startConfiguredSession(
context: RunnerContext,
vault: TemporaryVault,
overrides: Record<string, unknown> = {}
overrides: Record<string, unknown> = {},
persistedOverrides: Record<string, unknown> = overrides
): Promise<ObsidianLiveSyncSession> {
const couchDbSettings = {
uri: context.couchDb.uri,
@@ -452,7 +459,7 @@ async function startConfiguredSession(
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData(couchDbSettings, overrides),
pluginData: createE2eCouchDbPluginData(couchDbSettings, persistedOverrides),
});
context.activeSessions.add(session);
try {
@@ -961,6 +968,193 @@ async function runEncryptedRoundTrip(
console.log("Two-vault encrypted note synchronisation round-tripped.");
}
async function runIndependentIdRoundTrip(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
const source = "real-obsidian-e2e-independent-id-source";
const key = await deriveIdKey(source);
const content = "# Shared content with an independent ID key.\n";
const pathA = "E2E/independent-ids/from-a.md";
const pathB = "E2E/independent-ids/from-b.md";
const overrides = {
encrypt: true,
passphrase: "real-obsidian-e2e-independent-passphrase",
usePathObfuscation: true,
E2EEAlgorithm: "v2",
idDerivationVersion: 1,
idDerivationKey: key,
};
const persistedOverrides = {
...overrides,
idDerivationKey: "",
encryptedIdDerivationKey: await encryptString(key, `*${SALT_OF_PASSPHRASE}`),
};
let session = await startConfiguredSession(context, vaultA, overrides, persistedOverrides);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, pathA, content);
const entryA = await uploadNote(context, session, pathA);
if (entryA.children.length === 0) throw new Error("Independent ID mode produced no Chunks.");
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, overrides, persistedOverrides);
await syncAndApply(context, session);
await waitForPathContent(vaultB.path, pathA, (received) => received === content);
const receivedA = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, pathA);
assertEqual(receivedA.id, entryA.id, "The second device did not preserve the obfuscated document ID.");
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, pathB, content);
const entryB = await uploadNote(context, session, pathB);
assertEqual(
JSON.stringify(entryB.children),
JSON.stringify(entryA.children),
"The second device did not reuse the same content-derived Chunk IDs."
);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultA, overrides, persistedOverrides);
await syncAndApply(context, session);
await waitForPathContent(vaultA.path, pathB, (received) => received === content);
const receivedB = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, pathB);
assertEqual(receivedB.id, entryB.id, "The first device did not preserve the return document ID.");
await stopTrackedSession(context, session);
console.log("Two real Obsidian devices shared independent document and Chunk IDs in both directions.");
for (const [label, candidateKey] of [
["different key", "cd".repeat(32)],
["legacy IDs", ""],
] as const) {
const remoteBefore = await fetchAllCouchDbDocs(context.couchDb, context.dbName);
const checkpointsBefore = await fetchCouchDbLocalDocs(context.couchDb, context.dbName);
const rejectedVault = await createTemporaryVault();
let rejectedSession: ObsidianLiveSyncSession | undefined;
try {
rejectedSession = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault: rejectedVault,
localStorageEntries: createE2eObsidianDeviceLocalState(rejectedVault.name),
pluginData: createE2eCouchDbPluginData(
{ ...context.couchDb, dbName: context.dbName },
{
...overrides,
idDerivationVersion: candidateKey ? 1 : 0,
idDerivationKey: "",
encryptedIdDerivationKey: candidateKey
? await encryptString(candidateKey, `*${SALT_OF_PASSPHRASE}`)
: "",
}
),
});
context.activeSessions.add(rejectedSession);
await waitForLiveSyncCoreReady(context.cliBinary, rejectedSession.cliEnv);
const unsentPath = "E2E/independent-ids/rejected.md";
await writeNoteViaObsidian(context.cliBinary, rejectedSession.cliEnv, unsentPath, content);
await waitForLocalDatabaseEntry(context.cliBinary, rejectedSession.cliEnv, unsentPath);
const attempt = await evalObsidianJson<{ admitted: boolean; reason: string; replicated: boolean }>(
context.cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const replicator=core.services.replicator.getActiveReplicator();",
"const settings=core.services.setting.currentSettings();",
"let reason='';",
"const connection=await replicator.checkReplicationConnectivity(settings,false,false,false,false,undefined,(decision)=>{reason=decision.reason??'';});",
"if(connection) await connection.close();",
"const replicated=await core.services.replication.replicate(true);",
"return JSON.stringify({admitted:!!connection,reason,replicated:!!replicated});",
"})()",
].join(""),
rejectedSession.cliEnv
);
assertEqual(attempt.admitted, false, `CouchDB admitted ${label} for obfuscated document IDs.`);
assertEqual(
attempt.reason,
CENTRAL_COMPATIBILITY_REJECTION_REASONS.ID_DERIVATION_MISMATCH,
`CouchDB rejected ${label} for an unrelated reason.`
);
assertEqual(attempt.replicated, false, `Ordinary replication accepted ${label}.`);
assertEqual(
await pathExists(rejectedVault.path, pathA),
false,
"A rejected device received a remote note."
);
await stopTrackedSession(context, rejectedSession);
rejectedSession = undefined;
assertEqual(
JSON.stringify(await fetchAllCouchDbDocs(context.couchDb, context.dbName)),
JSON.stringify(remoteBefore),
`A rejected ${label} connection changed remote documents.`
);
assertEqual(
JSON.stringify(await fetchCouchDbLocalDocs(context.couchDb, context.dbName)),
JSON.stringify(checkpointsBefore),
`A rejected ${label} connection changed remote checkpoints.`
);
} finally {
if (rejectedSession) await stopTrackedSession(context, rejectedSession);
await rejectedVault.dispose();
}
}
console.log("Ordinary CouchDB replication rejected different and legacy document ID keys without remote writes.");
}
async function runDifferentChunkIdKeysRoundTrip(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
const keyA = await deriveIdKey("real-obsidian-e2e-chunk-source-a");
const keyB = await deriveIdKey("real-obsidian-e2e-chunk-source-b");
const content = "# Shared content with different Chunk ID keys.\n";
const pathA = "E2E/chunk-id-keys/from-a.md";
const pathB = "E2E/chunk-id-keys/from-b.md";
const commonSettings = {
encrypt: true,
passphrase: "real-obsidian-e2e-chunk-passphrase",
usePathObfuscation: false,
E2EEAlgorithm: "v2",
idDerivationVersion: 1,
};
const settingsFor = (key: string) => ({ ...commonSettings, idDerivationKey: key });
const persistedSettingsFor = async (key: string) => ({
...settingsFor(key),
idDerivationKey: "",
encryptedIdDerivationKey: await encryptString(key, `*${SALT_OF_PASSPHRASE}`),
});
const persistedA = await persistedSettingsFor(keyA);
const persistedB = await persistedSettingsFor(keyB);
let session = await startConfiguredSession(context, vaultA, settingsFor(keyA), persistedA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, pathA, content);
const entryA = await uploadNote(context, session, pathA);
if (entryA.children.length === 0) throw new Error("The first device produced no Chunks.");
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, settingsFor(keyB), persistedB);
await syncAndApply(context, session);
await waitForPathContent(vaultB.path, pathA, (received) => received === content);
const receivedA = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, pathA);
assertEqual(receivedA.id, entryA.id, "The second device changed the visible document ID.");
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, pathB, content);
const entryB = await uploadNote(context, session, pathB);
if (entryB.children.length === 0) throw new Error("The second device produced no Chunks.");
if (JSON.stringify(entryB.children) === JSON.stringify(entryA.children)) {
throw new Error("Different ID keys unexpectedly generated the same Chunk IDs.");
}
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultA, settingsFor(keyA), persistedA);
await syncAndApply(context, session);
await waitForPathContent(vaultA.path, pathB, (received) => received === content);
const receivedB = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, pathB);
assertEqual(receivedB.id, entryB.id, "The first device changed the return document ID.");
await stopTrackedSession(context, session);
console.log("Two real Obsidian devices exchanged notes with different Chunk ID keys and visible document paths.");
}
async function runMarkdownAutoMerge(
context: RunnerContext,
vaultA: TemporaryVault,
@@ -1085,10 +1279,9 @@ async function runConflictTimeStorageOperations(
showMergeDialogOnlyOnActive: true,
handleFilenameCaseSensitive: false,
};
const baseContent = Object.fromEntries(paths.map((path) => [path, `# Conflict operation\n\nBase for ${path}.\n`])) as Record<
(typeof paths)[number],
string
>;
const baseContent = Object.fromEntries(
paths.map((path) => [path, `# Conflict operation\n\nBase for ${path}.\n`])
) as Record<(typeof paths)[number], string>;
const leftContent = Object.fromEntries(
paths.map((path) => [path, `${baseContent[path]}\nEdit made on Vault A.\n`])
) as Record<(typeof paths)[number], string>;
@@ -1129,7 +1322,9 @@ async function runConflictTimeStorageOperations(
const initialBranchRevisions = new Map<string, Set<string>>();
for (const path of paths) {
const state = await waitForFileConflict(context.cliBinary, session.cliEnv, path);
const displayedBranch = state.branches.find((branch) => branch.content === rightContent[path] && !branch.deleted);
const displayedBranch = state.branches.find(
(branch) => branch.content === rightContent[path] && !branch.deleted
);
if (!displayedBranch) {
throw new Error(`Could not identify the branch displayed by Vault B: ${path}; ${JSON.stringify(state)}`);
}
@@ -1170,12 +1365,7 @@ async function runConflictTimeStorageOperations(
"A conflict-time deletion did not extend the displayed revision."
);
await renameNoteViaObsidian(
context.cliBinary,
session.cliEnv,
conflictCaseFromPath,
conflictCaseToPath
);
await renameNoteViaObsidian(context.cliBinary, session.cliEnv, conflictCaseFromPath, conflictCaseToPath);
const caseRenamedBranch = await waitForConflictBranch(
context.cliBinary,
session.cliEnv,
@@ -1216,12 +1406,7 @@ async function runConflictTimeStorageOperations(
"A conflict-time case-only rename did not record the new displayed revision."
);
await renameNoteViaObsidian(
context.cliBinary,
session.cliEnv,
conflictRenameFromPath,
conflictRenameToPath
);
await renameNoteViaObsidian(context.cliBinary, session.cliEnv, conflictRenameFromPath, conflictRenameToPath);
const renamedTarget = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictRenameToPath);
const renamedSourceDeletion = await waitForConflictBranch(
context.cliBinary,
@@ -1367,10 +1552,13 @@ async function main(): Promise<void> {
const couchDb = await loadCouchDbConfig();
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "two-vault-sync");
const encryptedDbName = makeUniqueDatabaseName(couchDb.dbPrefix, "two-vault-sync-e2ee");
const independentDbName = makeUniqueDatabaseName(couchDb.dbPrefix, "two-vault-sync-independent-ids");
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const encryptedVaultA = await createTemporaryVault();
const encryptedVaultB = await createTemporaryVault();
const independentVaultA = await createTemporaryVault();
const independentVaultB = await createTemporaryVault();
const context: RunnerContext = {
binary,
cliBinary: cli.binary,
@@ -1385,11 +1573,19 @@ async function main(): Promise<void> {
dbName: encryptedDbName,
activeSessions: new Set(),
};
const independentContext: RunnerContext = {
binary,
cliBinary: cli.binary,
couchDb,
dbName: independentDbName,
activeSessions: new Set(),
};
try {
await assertCouchDbReachable(couchDb);
await createCouchDbDatabase(couchDb, dbName);
await createCouchDbDatabase(couchDb, encryptedDbName);
await createCouchDbDatabase(couchDb, independentDbName);
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary vault A: ${vaultA.path}`);
@@ -1398,11 +1594,13 @@ async function main(): Promise<void> {
console.log(`Temporary encrypted CouchDB database: ${encryptedDbName}`);
const onlyParentCaseDeletion = process.env.E2E_OBSIDIAN_ONLY_PARENT_CASE_DELETION === "true";
const onlyIndependentIds = process.env.E2E_OBSIDIAN_ONLY_INDEPENDENT_IDS === "true";
const onlyDifferentChunkIdKeys = process.env.E2E_OBSIDIAN_ONLY_DIFFERENT_CHUNK_ID_KEYS === "true";
if (onlyParentCaseDeletion) {
await runParentCaseDeletionProtection(context, vaultA, vaultB);
}
const onlyConflictOperations = process.env.E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS === "true";
if (!onlyParentCaseDeletion && !onlyConflictOperations) {
if (!onlyParentCaseDeletion && !onlyConflictOperations && !onlyIndependentIds && !onlyDifferentChunkIdKeys) {
await runCreateUpdateDelete(context, vaultA, vaultB);
await runRename(context, vaultA, vaultB);
await runCaseOnlyRename(context, vaultA, vaultB);
@@ -1416,17 +1614,27 @@ async function main(): Promise<void> {
) {
await runConflictTimeStorageOperations(context, vaultA, vaultB);
}
if (!onlyParentCaseDeletion && !onlyConflictOperations) {
if (!onlyParentCaseDeletion && !onlyConflictOperations && !onlyIndependentIds && !onlyDifferentChunkIdKeys) {
await runTargetMismatch(context, vaultA, vaultB);
await runEncryptedRoundTrip(encryptedContext, encryptedVaultA, encryptedVaultB);
}
if (!onlyParentCaseDeletion && !onlyConflictOperations) {
if (onlyDifferentChunkIdKeys) {
await runDifferentChunkIdKeysRoundTrip(independentContext, independentVaultA, independentVaultB);
} else {
await runIndependentIdRoundTrip(independentContext, independentVaultA, independentVaultB);
}
}
} finally {
await stopTrackedSessions(context);
await stopTrackedSessions(encryptedContext);
await stopTrackedSessions(independentContext);
await vaultA.dispose();
await vaultB.dispose();
await encryptedVaultA.dispose();
await encryptedVaultB.dispose();
await independentVaultA.dispose();
await independentVaultB.dispose();
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
@@ -1434,6 +1642,9 @@ async function main(): Promise<void> {
await deleteCouchDbDatabase(couchDb, encryptedDbName).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
await deleteCouchDbDatabase(couchDb, independentDbName).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
}