import { createHash, randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { assertCouchDbReachable, createCouchDbDatabase, deleteCouchDbDatabase, fetchCouchDbDocument, loadCouchDbConfig, makeUniqueDatabaseName, putCouchDbDocument, type CouchDbConfig, type CouchDbDocument, } from "../runner/couchdb.ts"; import { evalObsidianJson } from "../runner/cli.ts"; import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts"; import { assertEqual, createE2eCouchDbPluginData, createE2eObsidianDeviceLocalState, prepareRemote, waitForLiveSyncCoreReady, } from "../runner/liveSyncWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000"; const fixtureContent = "# Synthetic remote fixture\n\nPlaintext body used by the isolated E2EE policy E2E.\n"; type RemoteFixture = { fixtureId: string; path: string; metadataId: string; metadataRev: string; chunkId: string; chunkRev: string; }; type ReplicationOutcome = { succeeded: boolean; idle: boolean; }; type LocalOutcome = { metadataPresent: boolean; metadataRevision: string | null; chunkPresent: boolean; decodedPlaintextPresent: boolean; vaultFilePresent: boolean; vaultContentMatches: boolean; }; type VaultFileState = { present: boolean; contentMatches: boolean; }; type DirectFetchOutcome = { reflected: boolean; requestedChunkCount: number; returnedChunkCount: number; }; type E2eePolicyUiOutcome = { initialEncryptionEnabled: boolean; initialAcceptanceChecked: boolean; acceptanceRowInAdvanced: boolean; acceptanceInitiallyEnabled: boolean; acceptanceCheckedAfterClick: boolean; acceptanceDisabledWhenEncryptionOff: boolean; acceptanceCheckedWhenEncryptionOff: boolean; acceptanceEnabledWhenEncryptionOn: boolean; acceptanceCheckedWhenEncryptionOn: boolean; warningMentionsThirdParty: boolean; warningMentionsPassphrase: boolean; warningMentionsDatabaseWriteInjection: boolean; cancelReturnedFalse: boolean; savedAcceptanceUnchanged: boolean; }; function fixturePath(fixtureId: string): string { return `E2E/e2ee-plaintext-policy/${fixtureId}.md`; } async function getMetadataId(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise { return await evalObsidianJson( cliBinary, [ "(async()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", `const path=${JSON.stringify(path)};`, "return JSON.stringify(await core.services.path.path2id(path));", "})()", ].join(""), env ); } async function injectPlaintextFixture( cliBinary: string, env: NodeJS.ProcessEnv, couchDb: CouchDbConfig, dbName: string, fixtureId: string ): Promise { const path = fixturePath(fixtureId); const metadataId = await getMetadataId(cliBinary, env, path); const chunkId = `h:e2ee-plaintext-policy-${randomUUID()}`; const now = Date.now(); const metadata: CouchDbDocument = { _id: metadataId, path, type: "plain", ctime: now, mtime: now, size: Buffer.byteLength(fixtureContent, "utf8"), children: [chunkId], eden: {}, }; const leaf: CouchDbDocument = { _id: chunkId, type: "leaf", data: fixtureContent }; const metadataPut = await putCouchDbDocument(couchDb, dbName, metadata); const leafPut = await putCouchDbDocument(couchDb, dbName, leaf); const remoteMetadata = await fetchCouchDbDocument(couchDb, dbName, metadataId); const remoteLeaf = await fetchCouchDbDocument(couchDb, dbName, chunkId); assertEqual(remoteMetadata.path === path, true, "The remote Metadata path did not match the synthetic fixture."); assertEqual(remoteMetadata.type === "plain", true, "The remote Metadata type was invalid."); assertEqual( remoteMetadata.size === Buffer.byteLength(fixtureContent, "utf8"), true, "The remote Metadata size was invalid." ); assertEqual( remoteMetadata.children?.length === 1 && remoteMetadata.children[0] === chunkId, true, "The remote Metadata did not reference the injected leaf." ); assertEqual(remoteLeaf.type === "leaf", true, "The injected remote document was not a leaf."); assertEqual( remoteLeaf.data === fixtureContent, true, "The injected remote leaf did not contain the synthetic fixture body." ); assertEqual(!("e_" in remoteLeaf), true, "The injected remote leaf unexpectedly had the encryption marker."); assertEqual( remoteMetadata._rev === metadataPut.rev, true, "The remote Metadata revision did not match the injected document." ); assertEqual(remoteLeaf._rev === leafPut.rev, true, "The remote leaf revision did not match the injected document."); return { fixtureId, path, metadataId, metadataRev: metadataPut.rev, chunkId, chunkRev: leafPut.rev, }; } async function runOneShotReplication( cliBinary: string, env: NodeJS.ProcessEnv, timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS ?? 30000) ): Promise { return await evalObsidianJson( cliBinary, [ "(async()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", `const timeoutMs=${JSON.stringify(timeoutMs)};`, "const succeeded=!!(await core.services.replication.replicate(true));", "const deadline=Date.now()+timeoutMs;", "let state;", "do{", " state={", " finite:core.services.replicator.finiteReplicationActivityCount.value,", " bounded:core.services.replicator.boundedRemoteActivityCount.value,", " results:core.services.replication.replicationResultCount.value,", " databaseQueue:core.services.replication.databaseQueueCount.value,", " storageApplying:core.services.replication.storageApplyingCount.value,", " };", " if(state.finite===0&&state.bounded===0&&state.results===0&&state.databaseQueue===0&&state.storageApplying===0){", " return JSON.stringify({succeeded,idle:true});", " }", " await new Promise(resolve=>setTimeout(resolve,100));", "}while(Date.now() { const local = await evalObsidianJson( cliBinary, [ "(async()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", `const path=${JSON.stringify(fixture.path)};`, `const metadataId=${JSON.stringify(fixture.metadataId)};`, `const chunkId=${JSON.stringify(fixture.chunkId)};`, `const content=${JSON.stringify(fixtureContent)};`, "const db=core.localDatabase.localDatabase;", "const rows=await db.allDocs({keys:[metadataId,chunkId],include_docs:true});", "const metadataRow=rows.rows.find(row=>row.id===metadataId&&row.doc&&!row.value?.deleted);", "const chunkRow=rows.rows.find(row=>row.id===chunkId&&row.doc&&!row.value?.deleted);", "const metadata=metadataRow?.doc;", "const chunk=chunkRow?.doc;", "const decoded=metadata&&chunk?await core.localDatabase.getDBEntryFromMeta(metadata,false,false,true).catch(()=>false):false;", "const decodedData=decoded?(Array.isArray(decoded.data)?decoded.data.join(''):decoded.data):undefined;", "const file=app.vault.getAbstractFileByPath(path);", "const decodedPlaintextPresent=decodedData===content;", "return JSON.stringify({", " metadataPresent:!!metadata,metadataRevision:metadata?._rev??null,chunkPresent:!!chunk,", " decodedPlaintextPresent,", " vaultFilePresent:!!file,", " vaultContentMatches:!!file&&(await app.vault.read(file))===content,", "});", "})()", ].join(""), env ); const diskState = await readVaultFileState(vault, fixture.path); return { ...local, vaultFilePresent: local.vaultFilePresent && diskState.present, vaultContentMatches: local.vaultContentMatches && diskState.contentMatches, }; } async function readVaultFileState(vault: TemporaryVault, path: string): Promise { try { return { present: true, contentMatches: (await readFile(join(vault.path, path), "utf8")) === fixtureContent }; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { present: false, contentMatches: false }; throw error; } } async function assertPlaintextRefused( cliBinary: string, env: NodeJS.ProcessEnv, vault: TemporaryVault, fixture: RemoteFixture ): Promise { const outcome = await readLocalOutcome(cliBinary, env, vault, fixture); assertEqual( outcome.chunkPresent, false, "The unencrypted leaf entered the local database while acceptance was disabled." ); assertEqual( outcome.decodedPlaintextPresent, false, "The synthetic plaintext body was readable from the local database while acceptance was disabled." ); assertEqual(outcome.vaultFilePresent, false, "A file was reflected into the Vault while acceptance was disabled."); assertEqual( outcome.vaultContentMatches, false, "The synthetic plaintext body was reflected into the Vault while acceptance was disabled." ); } async function assertPlaintextAccepted( cliBinary: string, env: NodeJS.ProcessEnv, vault: TemporaryVault, fixture: RemoteFixture ): Promise { const outcome = await readLocalOutcome(cliBinary, env, vault, fixture); assertEqual(outcome.metadataPresent, true, "The accepted remote Metadata was missing from the local database."); assertEqual( outcome.metadataRevision === fixture.metadataRev, true, "The accepted Metadata revision changed unexpectedly." ); assertEqual(outcome.chunkPresent, true, "The explicitly accepted leaf was not stored in the local database."); assertEqual(outcome.decodedPlaintextPresent, true, "The accepted body could not be read from the local database."); assertEqual(outcome.vaultFilePresent, true, "The explicitly accepted body did not create a Vault file."); assertEqual(outcome.vaultContentMatches, true, "The explicitly accepted body did not reach the Obsidian Vault."); } async function applySettingsPatch( cliBinary: string, env: NodeJS.ProcessEnv, patch: Record, expectedPolicy: boolean, expectedReplicatorReplacement: boolean ): Promise { const timeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000); const result = await evalObsidianJson<{ realised: number; accepted: boolean; replicatorReplaced: boolean; patchApplied: boolean; diagnostics: { before: { coreEncrypt: boolean; serviceEncrypt: boolean; encryptMatches: boolean; coreAcceptUnencryptedContent: boolean; serviceAcceptUnencryptedContent: boolean; acceptUnencryptedContentMatches: boolean; configurationIdentityPresent: boolean; providerName: string | null; }; after: { coreEncrypt: boolean; serviceEncrypt: boolean; encryptMatches: boolean; coreAcceptUnencryptedContent: boolean; serviceAcceptUnencryptedContent: boolean; acceptUnencryptedContentMatches: boolean; configurationIdentityPresent: boolean; providerName: string | null; }; configurationIdentityEqual: boolean | null; }; operationCalls: Record; }>( cliBinary, [ "(async()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", "const setting=core.services.setting;", "const rebuilder=core.rebuilder;", `const patch=${JSON.stringify(patch)};`, `const expectedPolicy=${JSON.stringify(expectedPolicy)};`, `const timeoutMs=${JSON.stringify(timeoutMs)};`, "const operationCalls={};", "const targets=[", " [rebuilder,'$fetchLocalDBFast','fastFetch'],", " [rebuilder,'$fetchLocal','fetch'],", " [rebuilder,'fetchLocal','fetchLocal'],", " [rebuilder,'fetchLocalDBFast','fetchLocalDBFast'],", " [rebuilder,'scheduleFetch','scheduleFetch'],", " [rebuilder,'$performRebuildDB','performRebuild'],", " [rebuilder,'rebuildRemote','rebuildRemote'],", " [rebuilder,'$rebuildRemote','rebuildRemoteInternal'],", " [rebuilder,'rebuildEverything','rebuildEverything'],", " [rebuilder,'$rebuildEverything','rebuildEverythingInternal'],", " [rebuilder,'scheduleRebuild','scheduleRebuild'],", " [rebuilder,'resetLocalDatabase','resetLocalDatabase'],", "];", "for(const [target,key] of [[rebuilder,'$fetchLocalDBFast'],[rebuilder,'$fetchLocal'],[rebuilder,'scheduleFetch'],[rebuilder,'$performRebuildDB'],[rebuilder,'scheduleRebuild']]){", " if(typeof target?.[key]!=='function')throw new Error('A required Fetch or Rebuild operation could not be observed.');", "}", "const restorers=[];", "for(const [target,key,label] of targets){", " if(typeof target?.[key]!=='function') continue;", " const descriptor=Object.getOwnPropertyDescriptor(target,key);", " const original=target[key];", " operationCalls[label]=0;", " Object.defineProperty(target,key,{configurable:true,writable:true,value:function(...args){operationCalls[label]++;return original.apply(this,args);}});", " restorers.push(()=>{if(descriptor)Object.defineProperty(target,key,descriptor);else delete target[key];});", "}", "const replicatorService=core.services.replicator;", "const before=replicatorService.getActiveReplicator();", "if(!before)throw new Error('No active CouchDB Replicator was available before settings apply.');", "const beforeContext=replicatorService.inspectActiveReplicatorContext?.();", "const readSettingsState=()=>{const coreEncrypt=core.settings.encrypt===true;const serviceEncrypt=setting.currentSettings().encrypt===true;const coreAcceptUnencryptedContent=core.settings.acceptUnencryptedContent===true;const serviceAcceptUnencryptedContent=setting.currentSettings().acceptUnencryptedContent===true;return {coreEncrypt,serviceEncrypt,encryptMatches:coreEncrypt===serviceEncrypt,coreAcceptUnencryptedContent,serviceAcceptUnencryptedContent,acceptUnencryptedContentMatches:coreAcceptUnencryptedContent===serviceAcceptUnencryptedContent};};", "const providerName=context=>{const name=context?.provider?.diagnosticName??context?.provider?.kind;return typeof name==='string'?name:null;};", "const beforeSettings=readSettingsState();", "const beforeIdentity=beforeContext?.configurationIdentity;", "let realised=0;", "let resolveRealised;", "const realisedPromise=new Promise(resolve=>{resolveRealised=resolve;});", "const unregister=setting.onSettingRealised.addHandler(()=>{realised++;resolveRealised();return Promise.resolve(true);});", "let timer;", "try{", " const apply=async()=>{await setting.applyPartial(patch,true);await realisedPromise;};", " await Promise.race([apply(),new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('Timed out waiting for settings to be realised.')),timeoutMs);})]);", " if(realised!==1)throw new Error('The setting-realisation handler did not run exactly once.');", " const after=replicatorService.getActiveReplicator();", " const afterContext=replicatorService.inspectActiveReplicatorContext?.();", " const afterSettings=readSettingsState();", " const afterIdentity=afterContext?.configurationIdentity;", " const actual=setting.currentSettings().acceptUnencryptedContent===true;", " const patchApplied=Object.entries(patch).every(([key,value])=>core.settings[key]===value&&setting.currentSettings()[key]===value);", " const identityPresent=typeof beforeIdentity==='string'&&typeof afterIdentity==='string';", " const providerNameBefore=providerName(beforeContext);", " const providerNameAfter=providerName(afterContext);", " return JSON.stringify({realised,accepted:actual,replicatorReplaced:!!after&&after!==before,patchApplied,diagnostics:{before:{...beforeSettings,configurationIdentityPresent:typeof beforeIdentity==='string',providerName:providerNameBefore},after:{...afterSettings,configurationIdentityPresent:typeof afterIdentity==='string',providerName:providerNameAfter},configurationIdentityEqual:identityPresent?beforeIdentity===afterIdentity:null},operationCalls});", "}finally{", " clearTimeout(timer);", " unregister();", " for(const restore of restorers.reverse())restore();", "}", "})()", ].join(""), env ); assertEqual(result.realised, 1, "The settings change did not reach onSettingRealised."); assertEqual(result.accepted, expectedPolicy, "The plaintext policy setting was not applied."); assertEqual(result.patchApplied, true, "The requested setting patch was not reflected in both settings views."); assertEqual( result.replicatorReplaced, expectedReplicatorReplacement, `Applying the setting did not meet the expected active Replicator replacement behaviour. Safe diagnostics: ${JSON.stringify(result.diagnostics)}` ); assertEqual( Object.values(result.operationCalls).every((count) => count === 0), true, "Changing the setting automatically invoked Fetch or Rebuild." ); } async function fetchAndReflectOnDemand( cliBinary: string, env: NodeJS.ProcessEnv, fixture: RemoteFixture, timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS ?? 30000) ): Promise { return await evalObsidianJson( cliBinary, [ "(async()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", `const path=${JSON.stringify(fixture.path)};`, `const chunkId=${JSON.stringify(fixture.chunkId)};`, `const timeoutMs=${JSON.stringify(timeoutMs)};`, "const metadata=await core.localDatabase.getDBEntryMeta(path,undefined,true);", "if(!metadata)throw new Error('The received Metadata is unavailable for direct Chunk retrieval.');", "const replicator=core.services.replicator.getActiveReplicator();", "if(!replicator||typeof replicator.fetchRemoteChunks!=='function')throw new Error('The active CouchDB Replicator has no direct Chunk retrieval method.');", "const descriptor=Object.getOwnPropertyDescriptor(replicator,'fetchRemoteChunks');", "const original=replicator.fetchRemoteChunks;", "let requestedChunkCount=0;let returnedChunkCount=0;", "replicator.fetchRemoteChunks=async function(ids,...args){", " if(ids.includes(chunkId))requestedChunkCount++;", " const result=await original.call(this,ids,...args);", " if(Array.isArray(result))returnedChunkCount+=result.filter(doc=>doc?._id===chunkId).length;", " return result;", "};", "let timer;", "try{", " const operation=core.serviceModules.fileHandler.dbToStorage(metadata,null,true);", " const reflected=!!(await Promise.race([operation,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('Timed out while reflecting the direct Chunk fixture.')),timeoutMs);})]));", " return JSON.stringify({reflected,requestedChunkCount,returnedChunkCount});", "}finally{", " clearTimeout(timer);", " if(descriptor)Object.defineProperty(replicator,'fetchRemoteChunks',descriptor);else delete replicator.fetchRemoteChunks;", "}", "})()", ].join(""), env ); } async function assertE2eeConfiguration(cliBinary: string, env: NodeJS.ProcessEnv): Promise { const settings = await evalObsidianJson<{ encrypted: boolean; algorithm: string; acceptUnencryptedContent: boolean; }>( cliBinary, [ "(()=>{", "const settings=app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings();", "return JSON.stringify({encrypted:settings.encrypt===true,algorithm:settings.E2EEAlgorithm,acceptUnencryptedContent:settings.acceptUnencryptedContent===true});", "})()", ].join(""), env ); assertEqual(settings.encrypted, true, "The E2EE fixture did not enable encryption."); assertEqual(settings.algorithm, "v2", "The E2EE fixture did not use V2."); assertEqual(settings.acceptUnencryptedContent, false, "The default plaintext policy did not deny acceptance."); } async function assertNonChunkReferenceRefused( cliBinary: string, env: NodeJS.ProcessEnv, couchDb: CouchDbConfig, dbName: string, vault: TemporaryVault ): Promise { const fixtureId = `non-chunk-${randomUUID()}`; const path = fixturePath(fixtureId); const metadataId = await getMetadataId(cliBinary, env, path); const chunkId = `soft-deleted-${randomUUID()}.md`; const now = Date.now(); const metadataPut = await putCouchDbDocument(couchDb, dbName, { _id: metadataId, path, type: "plain", ctime: now, mtime: now, size: Buffer.byteLength(fixtureContent, "utf8"), children: [chunkId], eden: {}, }); // Receive the referring Metadata before the remote body exists, so the // subsequent read must use the on-demand arrival path rather than a local row. const replication = await runOneShotReplication(cliBinary, env); assertEqual(replication.idle, true, "The non-Chunk fixture Metadata did not reach application quiescence."); const bodyPut = await putCouchDbDocument(couchDb, dbName, { _id: chunkId, type: "plain", deleted: true, data: fixtureContent, }); const fixture: RemoteFixture = { fixtureId, path, metadataId, metadataRev: metadataPut.rev, chunkId, chunkRev: bodyPut.rev, }; const before = await readLocalOutcome(cliBinary, env, vault, fixture); assertEqual(before.metadataPresent, true, "The referring Metadata was not received."); assertEqual(before.chunkPresent, false, "The non-Chunk fixture was already present locally."); const rejected = await fetchAndReflectOnDemand(cliBinary, env, fixture); assertEqual(rejected.requestedChunkCount > 0, true, "The non-Chunk fixture did not use on-demand retrieval."); assertEqual(rejected.returnedChunkCount > 0, true, "The control-exempt body did not reach Chunk validation."); const after = await readLocalOutcome(cliBinary, env, vault, fixture); const acceptance = { reflected: rejected.reflected, stored: after.chunkPresent, vaultFilePresent: after.vaultFilePresent, vaultContentMatches: after.vaultContentMatches, }; assertEqual( Object.values(acceptance).some(Boolean), false, `A non-Chunk document was accepted as file content: ${JSON.stringify(acceptance)}` ); await assertPlaintextRefused(cliBinary, env, vault, fixture); // A corrected remote leaf remains retrievable after opting in to plaintext. const repaired = await putCouchDbDocument(couchDb, dbName, { _id: chunkId, _rev: bodyPut.rev, type: "leaf", data: fixtureContent, }); await applySettingsPatch(cliBinary, env, { acceptUnencryptedContent: true }, true, true); const accepted = await fetchAndReflectOnDemand(cliBinary, env, { ...fixture, chunkRev: repaired.rev }); assertEqual(accepted.requestedChunkCount > 0, true, "The corrected Chunk was not requested again."); assertEqual(accepted.reflected, true, "The corrected plaintext leaf was not reflected after opt-in."); await assertPlaintextAccepted(cliBinary, env, vault, fixture); return fixtureId; } async function inspectE2eePolicyDialog(cliBinary: string, env: NodeJS.ProcessEnv): Promise { const timeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000); return await evalObsidianJson( cliBinary, [ "(async()=>{", "const timeoutMs=", JSON.stringify(timeoutMs), ";", "const deadline=Date.now()+timeoutMs;", "const waitFor=async(predicate,label,end=deadline)=>{while(Date.now()setTimeout(resolve,50));}throw new Error('Timed out while '+label+'.');};", "const core=app.plugins.plugins['obsidian-livesync'].core;", "const initialAcceptance=core.settings.acceptUnencryptedContent;", "const manager=core.modules.find(module=>module.constructor.name==='SetupManager');", "if(typeof manager?.onlyE2EEConfiguration!=='function')throw new Error('Could not find SetupManager.onlyE2EEConfiguration.');", "let dialogSettled=false;let dialogResult;let dialogFailure;let dialog=null;", "const dialogPromise=manager.onlyE2EEConfiguration('unknown',{...core.settings});", "void dialogPromise.then(result=>{dialogResult=result;dialogSettled=true;},error=>{dialogFailure=error;dialogSettled=true;});", "try{", " dialog=await waitFor(()=>document.querySelector('.sls-e2ee-dialog'),'opening the End-to-End Encryption dialogue');", " const findRow=label=>Array.from(dialog.querySelectorAll('label.row')).find(row=>row.querySelector('span')?.textContent?.trim()===label);", " const e2eeRow=findRow('End-to-End Encryption');", " if(!e2eeRow)throw new Error('The E2EE checkbox label row was not rendered.');", " const e2eeInput=e2eeRow.querySelector('input[type=checkbox]');", " if(!e2eeInput)throw new Error('The E2EE checkbox was not rendered.');", " const details=Array.from(dialog.querySelectorAll('details')).find(element=>element.querySelector('summary')?.textContent?.trim()==='Advanced');", " if(!details)throw new Error('The Advanced details section was not rendered.');", " details.querySelector('summary').click();", " await waitFor(()=>details.open,'opening the Advanced details section');", " const acceptanceRow=findRow('Accept unencrypted content');", " if(!acceptanceRow)throw new Error('The plaintext acceptance checkbox label row was not rendered.');", " const acceptanceInput=acceptanceRow.querySelector('input[type=checkbox]');", " if(!acceptanceInput)throw new Error('The plaintext acceptance checkbox was not rendered.');", " const acceptanceRowInAdvanced=details.contains(acceptanceRow);", " const initialEncryptionEnabled=e2eeInput.checked;", " const initialAcceptanceChecked=acceptanceInput.checked;", " const acceptanceInitiallyEnabled=!acceptanceInput.disabled;", " const warningText=details.textContent??'';", " const warningMentionsThirdParty=warningText.includes('third-party tools can add content');", " const warningMentionsPassphrase=warningText.includes('without the E2EE passphrase');", " const warningMentionsDatabaseWriteInjection=warningText.includes('Anyone with database write access can inject plaintext');", " acceptanceInput.click();", " await waitFor(()=>acceptanceInput.checked,'enabling plaintext acceptance');", " const acceptanceCheckedAfterClick=acceptanceInput.checked;", " e2eeInput.click();", " await waitFor(()=>!e2eeInput.checked&&acceptanceInput.disabled&&acceptanceInput.checked,'disabling E2EE while retaining plaintext acceptance');", " const acceptanceDisabledWhenEncryptionOff=acceptanceInput.disabled;", " const acceptanceCheckedWhenEncryptionOff=acceptanceInput.checked;", " e2eeInput.click();", " await waitFor(()=>e2eeInput.checked&&!acceptanceInput.disabled&&acceptanceInput.checked,'re-enabling E2EE with plaintext acceptance retained');", " const acceptanceEnabledWhenEncryptionOn=!acceptanceInput.disabled;", " const acceptanceCheckedWhenEncryptionOn=acceptanceInput.checked;", " const cancel=Array.from(dialog.querySelectorAll('button')).find(button=>button.textContent?.trim()==='Cancel');", " if(!cancel)throw new Error('The E2EE dialogue Cancel button was not rendered.');", " cancel.click();", " await waitFor(()=>dialogSettled,'waiting for SetupManager dialogue completion');", " if(dialogFailure)throw new Error('SetupManager dialogue failed.');", " return JSON.stringify({initialEncryptionEnabled,initialAcceptanceChecked,acceptanceRowInAdvanced,acceptanceInitiallyEnabled,acceptanceCheckedAfterClick,acceptanceDisabledWhenEncryptionOff,acceptanceCheckedWhenEncryptionOff,acceptanceEnabledWhenEncryptionOn,acceptanceCheckedWhenEncryptionOn,warningMentionsThirdParty,warningMentionsPassphrase,warningMentionsDatabaseWriteInjection,cancelReturnedFalse:dialogResult===false,savedAcceptanceUnchanged:core.settings.acceptUnencryptedContent===initialAcceptance&&core.services.setting.currentSettings().acceptUnencryptedContent===initialAcceptance});", "}finally{", " if(!dialogSettled){", " const root=dialog??document.querySelector('.sls-e2ee-dialog');", " const cancel=root&&Array.from(root.querySelectorAll('button')).find(button=>button.textContent?.trim()==='Cancel');", " if(cancel)cancel.click();", " await waitFor(()=>dialogSettled,'cleaning up the E2EE dialogue',Date.now()+timeoutMs);", " }", "}", "})()", ].join(""), env ); } async function main(): Promise { const binary = requireObsidianBinary(); const cliBinary = requireObsidianCli(); const artifactRoot = process.env.E2E_OBSIDIAN_ARTIFACT_ROOT ?? process.cwd(); const couchDb = await loadCouchDbConfig(); await assertCouchDbReachable(couchDb); const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "e2ee-plaintext-policy"); const connection = { ...couchDb, dbName }; const vault = await createTemporaryVault("obsidian-livesync-e2ee-plaintext-policy-"); let session: ObsidianLiveSyncSession | undefined; let databaseCreated = false; try { await createCouchDbDatabase(couchDb, dbName); databaseCreated = true; session = await startObsidianLiveSyncSession({ binary, cliBinary, vault, artifactRoot, pluginData: createE2eCouchDbPluginData(connection, { encrypt: true, E2EEAlgorithm: "v2", passphrase: "synthetic-e2ee-plaintext-policy-fixture", acceptUnencryptedContent: false, encryptInternalMetadata: false, usePathObfuscation: false, readChunksOnline: false, useOnlyLocalChunk: false, useRequestAPI: true, periodicReplication: false, syncOnStart: false, syncOnSave: false, syncOnFileOpen: false, syncOnEditorSave: false, syncAfterMerge: false, }), localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), }); await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); await prepareRemote(cliBinary, session.cliEnv); await assertE2eeConfiguration(cliBinary, session.cliEnv); const e2eePolicyUi = await inspectE2eePolicyDialog(cliBinary, session.cliEnv); assertEqual( e2eePolicyUi.initialEncryptionEnabled, true, "The E2EE configuration dialogue did not open with encryption enabled." ); assertEqual( e2eePolicyUi.initialAcceptanceChecked, false, "The E2EE configuration dialogue did not open with plaintext acceptance disabled." ); assertEqual( e2eePolicyUi.acceptanceRowInAdvanced, true, "The plaintext acceptance control was outside the Advanced details section." ); assertEqual( e2eePolicyUi.acceptanceInitiallyEnabled, true, "The plaintext acceptance control was disabled while E2EE was enabled." ); assertEqual( e2eePolicyUi.acceptanceCheckedAfterClick, true, "The plaintext acceptance control did not bind its checked state." ); assertEqual( e2eePolicyUi.acceptanceDisabledWhenEncryptionOff, true, "The plaintext acceptance control remained enabled while E2EE was disabled." ); assertEqual( e2eePolicyUi.acceptanceCheckedWhenEncryptionOff, true, "Disabling E2EE discarded the selected plaintext acceptance value." ); assertEqual( e2eePolicyUi.acceptanceEnabledWhenEncryptionOn, true, "The plaintext acceptance control remained disabled after re-enabling E2EE." ); assertEqual( e2eePolicyUi.acceptanceCheckedWhenEncryptionOn, true, "Re-enabling E2EE discarded the selected plaintext acceptance value." ); assertEqual( e2eePolicyUi.warningMentionsThirdParty, true, "The plaintext acceptance warning did not explain third-party content injection." ); assertEqual( e2eePolicyUi.warningMentionsPassphrase, true, "The plaintext acceptance warning did not explain that the E2EE passphrase is not required." ); assertEqual( e2eePolicyUi.warningMentionsDatabaseWriteInjection, true, "The plaintext acceptance warning did not explain database-write injection risk." ); assertEqual( e2eePolicyUi.cancelReturnedFalse, true, "Cancelling the E2EE configuration dialogue did not return false." ); assertEqual( e2eePolicyUi.savedAcceptanceUnchanged, true, "Cancelling the E2EE configuration dialogue changed the saved plaintext policy." ); const ordinary = await injectPlaintextFixture( cliBinary, session.cliEnv, couchDb, dbName, `ordinary-${randomUUID()}` ); const ordinaryReplication = await runOneShotReplication(cliBinary, session.cliEnv); assertEqual(ordinaryReplication.idle, true, "The ordinary CouchDB pull did not reach application quiescence."); await assertPlaintextRefused(cliBinary, session.cliEnv, vault, ordinary); assertEqual( ordinaryReplication.succeeded, false, "The ordinary CouchDB pull did not fail when it received a forbidden plaintext leaf." ); await applySettingsPatch(cliBinary, session.cliEnv, { acceptUnencryptedContent: true }, true, true); await assertPlaintextRefused(cliBinary, session.cliEnv, vault, ordinary); const acceptedReplication = await runOneShotReplication(cliBinary, session.cliEnv); assertEqual( acceptedReplication.succeeded, true, "The ordinary CouchDB pull did not accept the same remote plaintext leaf after policy apply." ); assertEqual( acceptedReplication.idle, true, "The accepted ordinary CouchDB pull did not reach application quiescence." ); const ordinaryRemoteMetadata = await fetchCouchDbDocument(couchDb, dbName, ordinary.metadataId); const ordinaryRemoteLeaf = await fetchCouchDbDocument(couchDb, dbName, ordinary.chunkId); assertEqual( ordinaryRemoteMetadata._rev === ordinary.metadataRev, true, "The ordinary Metadata revision changed during acceptance." ); assertEqual( ordinaryRemoteLeaf._rev === ordinary.chunkRev, true, "The ordinary leaf revision changed during acceptance." ); assertEqual( !("e_" in ordinaryRemoteLeaf), true, "The accepted ordinary remote leaf unexpectedly had the encryption marker." ); await assertPlaintextAccepted(cliBinary, session.cliEnv, vault, ordinary); await applySettingsPatch(cliBinary, session.cliEnv, { acceptUnencryptedContent: false }, false, true); await applySettingsPatch(cliBinary, session.cliEnv, { readChunksOnline: true }, false, false); const onDemand = await injectPlaintextFixture( cliBinary, session.cliEnv, couchDb, dbName, `on-demand-${randomUUID()}` ); const onDemandReplication = await runOneShotReplication(cliBinary, session.cliEnv); assertEqual( onDemandReplication.idle, true, "The on-demand Metadata pull did not reach application quiescence." ); const onDemandMetadata = await evalObsidianJson<{ present: boolean; revision: string | null }>( cliBinary, [ "(async()=>{", `const path=${JSON.stringify(onDemand.path)};`, "const metadata=await app.plugins.plugins['obsidian-livesync'].core.localDatabase.getDBEntryMeta(path,undefined,true);", "return JSON.stringify({present:!!metadata,revision:metadata?._rev??null});", "})()", ].join(""), session.cliEnv ); assertEqual(onDemandMetadata.present, true, "The on-demand pull did not receive the valid Metadata."); assertEqual( onDemandMetadata.revision === onDemand.metadataRev, true, "The on-demand pull received an unexpected Metadata revision." ); await assertPlaintextRefused(cliBinary, session.cliEnv, vault, onDemand); const deniedDirectFetch = await fetchAndReflectOnDemand(cliBinary, session.cliEnv, onDemand); assertEqual( deniedDirectFetch.requestedChunkCount > 0, true, "The denied direct read did not request the remote Chunk." ); assertEqual(deniedDirectFetch.returnedChunkCount, 0, "The denied direct read returned the plaintext Chunk."); assertEqual(deniedDirectFetch.reflected, false, "The denied direct read reflected the plaintext body."); await assertPlaintextRefused(cliBinary, session.cliEnv, vault, onDemand); const onDemandMetadataAfterDenial = await evalObsidianJson<{ present: boolean; revision: string | null }>( cliBinary, [ "(async()=>{", `const path=${JSON.stringify(onDemand.path)};`, "const metadata=await app.plugins.plugins['obsidian-livesync'].core.localDatabase.getDBEntryMeta(path,undefined,true);", "return JSON.stringify({present:!!metadata,revision:metadata?._rev??null});", "})()", ].join(""), session.cliEnv ); assertEqual(onDemandMetadataAfterDenial.present, true, "The rejected on-demand read removed its Metadata."); assertEqual( onDemandMetadataAfterDenial.revision === onDemand.metadataRev, true, "The rejected on-demand read changed its Metadata revision." ); await applySettingsPatch(cliBinary, session.cliEnv, { acceptUnencryptedContent: true }, true, true); const acceptedDirectFetch = await fetchAndReflectOnDemand(cliBinary, session.cliEnv, onDemand); assertEqual( acceptedDirectFetch.requestedChunkCount > 0, true, "The accepted direct read did not request the remote Chunk again." ); assertEqual( acceptedDirectFetch.returnedChunkCount > 0, true, "The accepted direct read did not receive the same remote Chunk." ); assertEqual( acceptedDirectFetch.reflected, true, "The accepted direct read did not reflect the plaintext body." ); const onDemandRemoteMetadata = await fetchCouchDbDocument(couchDb, dbName, onDemand.metadataId); const onDemandRemoteLeaf = await fetchCouchDbDocument(couchDb, dbName, onDemand.chunkId); assertEqual( onDemandRemoteMetadata._rev === onDemand.metadataRev, true, "The on-demand Metadata revision changed during acceptance." ); assertEqual( onDemandRemoteLeaf._rev === onDemand.chunkRev, true, "The on-demand leaf revision changed during acceptance." ); assertEqual( !("e_" in onDemandRemoteLeaf), true, "The accepted on-demand remote leaf unexpectedly had the encryption marker." ); await assertPlaintextAccepted(cliBinary, session.cliEnv, vault, onDemand); await applySettingsPatch(cliBinary, session.cliEnv, { acceptUnencryptedContent: false }, false, true); const nonChunkFixtureId = await assertNonChunkReferenceRefused( cliBinary, session.cliEnv, couchDb, dbName, vault ); const artifactHash = createHash("sha256") .update(new Uint8Array(await readFile(join(artifactRoot, "main.js")))) .digest("hex"); console.log( JSON.stringify( { ok: true, scenario: "e2ee-plaintext-policy", artifactHash, fixtureIds: [ordinary.fixtureId, onDemand.fixtureId, nonChunkFixtureId], assertions: { remoteLeafHadNoEncryptionMarker: true, defaultOrdinaryPullRejectedThePlaintextLeafWithoutStoringOrReflectingIt: true, policyApplyReachedSettingRealisedAndReplacedTheReplicatorWithoutFetchOrRebuild: true, sameRemoteMetadataAndLeafRevisionWereAcceptedAfterPolicyChange: true, onDemandMetadataArrivedBeforePlaintextWasReflected: true, directChunkReadWasDeniedThenRetriedAndReflectedAfterPolicyChange: true, e2eeDialogBoundThePlaintextPolicyAndCancelLeftItUnchanged: true, nonChunkReferenceWasRejectedBeforeStorageAndVaultReflection: true, correctedRemoteLeafRemainedRetrievableAfterOptIn: true, }, }, null, 2 ) ); } finally { if (session) await session.app.stop(); await vault.dispose(); if (databaseCreated) await deleteCouchDbDatabase(couchDb, dbName); } } main().catch((error: unknown) => { console.error(error instanceof Error ? error.stack : error); process.exitCode = 1; });