feat(sync): offer E2EE before first upload for file-based providers (#8709)

* feat(sync): offer E2EE at setup for file-based providers

File-based providers (WebDAV, Nextcloud, Dropbox, OneDrive, local file)
support optional client-side encryption but, unlike SuperSync, have no
mandatory-encryption upload guard — so their first setup sync would ship
plaintext before a user could enable E2EE.

Offer to set an encryption password during first-time setup and persist it
as part of the SAME config save: the key goes to the provider's privateCfg
and isEncryptionEnabled to the global config, atomically with isEnabled.
The normal first sync then encrypts from the first op via the standard
download-first flow — no separate snapshot-overwrite and no plaintext-upload
race. Skipping keeps the existing unencrypted behavior; works offline too.

Reuses DialogEnableEncryptionComponent in a new side-effect-free
collectPasswordOnly mode (returns the password, writes nothing), with a
provider-aware Skip action for the disableClose setup modal.

* test(sync): skip file-based setup encryption prompt in webdav e2e helper

Fresh file-based setup now opens the optional "Encrypt before first upload?"
dialog before the config is persisted, so setupWebdavSync would stall on the
disableClose modal. Dismiss it via a new Skip selector so every WebDAV spec
proceeds unencrypted, exactly as before; encryption specs still enable it
afterwards via enableEncryption().

* test(sync): add e2e for file-based setup-time encryption

Adds a @webdav @encryption spec that configures WebDAV with the encryption
password set in the setup dialog, then asserts the first upload's remote
sync-data.json does NOT contain the task title in plaintext (compression is
off by default, so a plaintext upload would) — proving the first sync is
encrypted — and that a second client with the same password decrypts and
receives the task without overwriting the remote.

setupWebdavSync gains an encryptAtSetup option (fills the new dialog instead
of skipping it); adds e2e selectors on the setup dialog password/confirm/
submit controls.

* ci(e2e): allow targeting the webdav job via workflow_dispatch grep

The webdav e2e job hardcoded --grep "@webdav"; add a webdav_grep dispatch
input (default @webdav, mirroring the supersync job) so a specific WebDAV
test can be run on demand.

* test(sync): fix remote sync-file path in setup-encryption e2e

Non-production builds nest the file under a /DEV segment
(sync-providers.factory.ts). The raw-fetch assertion omitted it and 404'd;
the CI trace confirmed the app uploaded to <folder>/DEV/sync-data.json and
that the first upload was encrypted.

* test(sync): cover joining an unencrypted remote with setup-time encryption

Adds the data-safety edge case for file-based setup-time E2EE: a client that
sets a password at setup while joining a remote that already holds UNENCRYPTED
data. Asserts the join reads and preserves the existing data (does not overwrite
the remote with the joining client's empty state) and that a subsequent write
upgrades the remote to encrypted (no plaintext titles remain).
This commit is contained in:
Johannes Millan 2026-07-02 17:17:01 +02:00 committed by GitHub
parent 5c9c0c2131
commit 10a47888a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 654 additions and 12 deletions

View file

@ -11,6 +11,11 @@ on:
required: false
type: string
default: '@supersync'
webdav_grep:
description: 'Playwright --grep filter for the WebDAV job (default: @webdav)'
required: false
type: string
default: '@webdav'
push:
branches: [master, release/*]
@ -143,7 +148,7 @@ jobs:
- name: Run WebDAV E2E Tests
env:
E2E_REQUIRE_WEBDAV: 'true'
run: npm run e2e:all -- --grep "@webdav"
run: npm run e2e:all -- --grep "${{ inputs.webdav_grep || '@webdav' }}"
- name: Stop Docker Services
if: always()

View file

@ -20,6 +20,8 @@ Sync providers **synchronize your full Super Productivity data** (tasks, project
**Note:** SuperSync is very new and is still in beta. Prefer Nextcloud, WebDAV, Dropbox, or local file for production use until SuperSync is stable.
**Encryption timing:** When you first enable a file-based provider (Nextcloud, WebDAV, Dropbox, OneDrive, or local file), you are offered the option to set an encryption password as part of the setup. If you do, the password is saved with the sync config, so the very first upload — and every upload after — is encrypted; nothing this device uploads leaves it unencrypted. Encryption stays optional: you can skip the prompt and enable, change, or disable it later from the sync settings.
## Per-Provider Details
### Nextcloud

View file

@ -46,6 +46,12 @@ export class SyncPage extends BasePage {
syncFolderPath: string;
isEncryptionEnabled?: boolean;
encryptionPassword?: string;
/**
* Set the encryption password in the setup-time "Encrypt before first
* upload?" dialog (instead of the post-setup Enable Encryption button), so
* the very first sync is encrypted. Requires `encryptionPassword`.
*/
encryptAtSetup?: boolean;
}): Promise<void> {
// Try entire setup flow up to 2 times (dialog-level retry)
for (let dialogAttempt = 0; dialogAttempt < 2; dialogAttempt++) {
@ -197,11 +203,27 @@ export class SyncPage extends BasePage {
// Save the configuration
await this.saveBtn.click();
// A fresh file-based setup now opens the optional "Encrypt before first
// upload?" dialog (disableClose) before the config is persisted. Either
// set the password here (setup-time E2EE) or skip it so setup proceeds
// unencrypted.
if (config.encryptAtSetup && config.encryptionPassword) {
await this._fillSetupEncryptionDialog(config.encryptionPassword);
} else {
await this._skipSetupEncryptionDialogIfPresent();
}
// Wait for dialog to close
await dialog.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
await this.page.waitForTimeout(500);
if (config.isEncryptionEnabled && config.encryptionPassword) {
// Legacy path: enable encryption via the post-setup Enable Encryption
// button (skipped when it was already set at setup above).
if (
config.isEncryptionEnabled &&
config.encryptionPassword &&
!config.encryptAtSetup
) {
await this.waitForSyncReady();
await this.enableEncryption(config.encryptionPassword);
}
@ -225,6 +247,47 @@ export class SyncPage extends BasePage {
);
}
/**
* Dismisses the optional "Encrypt before first upload?" setup dialog that
* opens on a fresh file-based setup (see DialogSyncCfgComponent.save()). Clicks
* Skip so setup stays unencrypted. No-op if the dialog did not appear.
*/
private async _skipSetupEncryptionDialogIfPresent(): Promise<void> {
const skipBtn = this.page.locator('.e2e-setup-encrypt-skip');
if (await skipBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
await skipBtn.click();
}
}
/**
* Fills the setup-time "Encrypt before first upload?" dialog with the given
* password and submits, so the config save persists the key and the first
* sync is encrypted. Mirrors the robust fill used by enableEncryption().
*/
private async _fillSetupEncryptionDialog(password: string): Promise<void> {
const passwordInput = this.page.locator('.e2e-setup-encrypt-password');
const confirmInput = this.page.locator('.e2e-setup-encrypt-confirm-password');
const submitBtn = this.page.locator('.e2e-setup-encrypt-submit');
await passwordInput.waitFor({ state: 'visible', timeout: 5000 });
await passwordInput.click();
await passwordInput.fill(password);
await expect(passwordInput).toHaveValue(password);
// Insert into the focused confirm field — fill() has occasionally left the
// value on the first field in CI (see enableEncryption()).
await confirmInput.click();
await expect(confirmInput).toBeFocused();
await this.page.keyboard.press('ControlOrMeta+A');
await this.page.keyboard.insertText(password);
await expect(confirmInput).toHaveValue(password);
await expect(passwordInput).toHaveValue(password);
await expect(submitBtn).toBeEnabled({ timeout: 5000 });
await submitBtn.click();
await submitBtn.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
}
async triggerSync(): Promise<void> {
// Dismiss any open dialogs/overlays that might block the sync button
const overlay = this.page.locator('.cdk-overlay-backdrop');

View file

@ -0,0 +1,248 @@
import { test, expect } from '../../fixtures/webdav.fixture';
import { SyncPage } from '../../pages/sync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { waitForStatePersistence } from '../../utils/waits';
import {
WEBDAV_CONFIG_TEMPLATE,
setupSyncClient,
createSyncFolder,
waitForSyncComplete,
generateSyncFolderName,
closeContextsSafely,
} from '../../utils/sync-helpers';
/**
* WebDAV (File-Based) Setup-Time Encryption E2E Test
*
* Verifies the file-based "E2EE before first upload" feature: when the user sets
* an encryption password DURING first-time setup, the key is persisted with the
* config so the very FIRST sync is already encrypted (no plaintext window), via
* the normal download-first sync flow (no snapshot-overwrite).
*
* Flow:
* 1. Client A configures WebDAV and sets an encryption password at setup.
* 2. Client A adds a task and performs its first sync.
* 3. The raw remote sync file is fetched and asserted to NOT contain the task
* title in plaintext (compression is off by default, so an unencrypted upload
* would contain it verbatim) proving the first upload was encrypted.
* 4. Client B configures WebDAV with the SAME password at setup and receives
* A's task (round-trips with the key; no overwrite of the remote).
*
* Run with: npm run e2e:file e2e/tests/sync/webdav-setup-encryption.spec.ts
*/
test.describe('@webdav @encryption WebDAV Setup-Time Encryption', () => {
test.describe.configure({ mode: 'serial' });
const SYNC_FOLDER_NAME = generateSyncFolderName('e2e-setup-encrypt');
const ENCRYPTION_PASSWORD = 'setup-password-123';
const WEBDAV_CONFIG = {
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${SYNC_FOLDER_NAME}`,
};
// Non-production builds nest the sync file under a `/DEV` segment
// (`environment.production ? undefined : '/DEV'` in sync-providers.factory.ts).
// E2E always runs a non-production build, so include it here.
const SYNC_FILE_URL = `${WEBDAV_CONFIG_TEMPLATE.baseUrl}${SYNC_FOLDER_NAME}/DEV/sync-data.json`;
const AUTH_HEADER =
'Basic ' +
Buffer.from(
`${WEBDAV_CONFIG_TEMPLATE.username}:${WEBDAV_CONFIG_TEMPLATE.password}`,
).toString('base64');
test('encrypts the first upload when the password is set at setup, and a same-password client can read it', async ({
browser,
baseURL,
request,
}) => {
test.slow(); // Sync tests take longer
const url = baseURL || 'http://localhost:4242';
const uniqueId = Date.now();
const taskTitle = `SetupEncryptedTask-${uniqueId}`;
await createSyncFolder(request, SYNC_FOLDER_NAME);
// ============ PHASE 1: Client A sets the password AT SETUP ============
console.log('[SetupEncrypt] Phase 1: Client A setup with setup-time encryption');
const { context: contextA, page: pageA } = await setupSyncClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
await workViewPageA.waitForTaskList();
await syncPageA.setupWebdavSync({
...WEBDAV_CONFIG,
encryptAtSetup: true,
encryptionPassword: ENCRYPTION_PASSWORD,
});
await expect(syncPageA.syncBtn).toBeVisible();
// Add a task and perform the FIRST sync — this upload must already be encrypted.
await workViewPageA.addTask(taskTitle);
await expect(pageA.locator('task')).toHaveCount(1);
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
console.log(`[SetupEncrypt] Client A synced first task: ${taskTitle}`);
// ============ PHASE 2: The remote blob must NOT be plaintext ============
console.log('[SetupEncrypt] Phase 2: Verifying the remote file is encrypted');
const remote = await request.fetch(SYNC_FILE_URL, {
headers: { Authorization: AUTH_HEADER },
});
expect(remote.ok()).toBeTruthy();
const remoteBody = await remote.text();
expect(remoteBody.length).toBeGreaterThan(0);
// Compression is off by default, so a plaintext upload would contain the
// task title verbatim. Its absence proves the first upload was encrypted.
expect(remoteBody).not.toContain(taskTitle);
console.log('[SetupEncrypt] Remote file does not contain the plaintext task');
// ============ PHASE 3: Client B joins with the SAME password ============
console.log('[SetupEncrypt] Phase 3: Client B joins with the same password');
const { context: contextB, page: pageB } = await setupSyncClient(browser, url);
const syncPageB = new SyncPage(pageB);
const workViewPageB = new WorkViewPage(pageB);
await workViewPageB.waitForTaskList();
await syncPageB.setupWebdavSync({
...WEBDAV_CONFIG,
encryptAtSetup: true,
encryptionPassword: ENCRYPTION_PASSWORD,
});
await expect(syncPageB.syncBtn).toBeVisible();
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
// B decrypts with the key and receives A's task (and did not overwrite it).
await expect(pageB.locator(`task:has-text("${taskTitle}")`).first()).toBeVisible();
console.log('[SetupEncrypt] ✓ Client B decrypted and received the task');
await closeContextsSafely(contextA, contextB);
});
});
/**
* Guards the data-safety edge case: a client that sets an encryption password at
* setup while joining a remote that ALREADY holds UNENCRYPTED data (returning
* user / new device on an existing plaintext remote). The normal download-first
* sync must read the plaintext remote, keep the data, and re-upload it encrypted
* never overwrite the remote with the joining client's (empty) state.
*/
test.describe('@webdav @encryption WebDAV Setup-Time Encryption — Unencrypted Remote', () => {
test.describe.configure({ mode: 'serial' });
const SYNC_FOLDER_NAME = generateSyncFolderName('e2e-setup-encrypt-migrate');
const ENCRYPTION_PASSWORD = 'migrate-password-123';
const WEBDAV_CONFIG = {
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${SYNC_FOLDER_NAME}`,
};
// Non-production builds nest the sync file under a `/DEV` segment (see above).
const SYNC_FILE_URL = `${WEBDAV_CONFIG_TEMPLATE.baseUrl}${SYNC_FOLDER_NAME}/DEV/sync-data.json`;
const AUTH_HEADER =
'Basic ' +
Buffer.from(
`${WEBDAV_CONFIG_TEMPLATE.username}:${WEBDAV_CONFIG_TEMPLATE.password}`,
).toString('base64');
test('preserves data and upgrades the remote to encrypted when a setup-time-encryption client joins an unencrypted remote', async ({
browser,
baseURL,
request,
}) => {
test.slow(); // Sync tests take longer
const url = baseURL || 'http://localhost:4242';
const uniqueId = Date.now();
const taskFromA = `MigrateTaskA-${uniqueId}`;
const taskFromB = `MigrateTaskB-${uniqueId}`;
await createSyncFolder(request, SYNC_FOLDER_NAME);
// ============ PHASE 1: Client A seeds the remote UNENCRYPTED ============
console.log('[SetupMigrate] Phase 1: Client A (no encryption) seeds the remote');
const { context: contextA, page: pageA } = await setupSyncClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
await workViewPageA.waitForTaskList();
await syncPageA.setupWebdavSync({ ...WEBDAV_CONFIG }); // skips the setup dialog
await expect(syncPageA.syncBtn).toBeVisible();
await workViewPageA.addTask(taskFromA);
await expect(pageA.locator('task')).toHaveCount(1);
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
// Baseline: the remote is genuinely UNENCRYPTED (title present in plaintext).
const before = await request.fetch(SYNC_FILE_URL, {
headers: { Authorization: AUTH_HEADER },
});
expect(before.ok()).toBeTruthy();
expect(await before.text()).toContain(taskFromA);
console.log('[SetupMigrate] Remote seeded unencrypted (plaintext title present)');
// ============ PHASE 2: Client B joins it WITH setup-time encryption ============
console.log('[SetupMigrate] Phase 2: Client B joins with setup-time encryption');
const { context: contextB, page: pageB } = await setupSyncClient(browser, url);
const syncPageB = new SyncPage(pageB);
const workViewPageB = new WorkViewPage(pageB);
await workViewPageB.waitForTaskList();
await syncPageB.setupWebdavSync({
...WEBDAV_CONFIG,
encryptAtSetup: true,
encryptionPassword: ENCRYPTION_PASSWORD,
});
await expect(syncPageB.syncBtn).toBeVisible();
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
// DATA SAFETY: B must have downloaded A's task — not overwritten the remote
// with its own empty state (the failure mode this test guards).
await expect(pageB.locator(`task:has-text("${taskFromA}")`).first()).toBeVisible();
console.log('[SetupMigrate] Client B preserved A data (received the task)');
// ============ PHASE 3: B writes → remote is upgraded to encrypted ============
await workViewPageB.addTask(taskFromB);
await waitForStatePersistence(pageB);
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
// The remote is now encrypted: neither title appears in plaintext anymore.
const after = await request.fetch(SYNC_FILE_URL, {
headers: { Authorization: AUTH_HEADER },
});
expect(after.ok()).toBeTruthy();
const afterBody = await after.text();
expect(afterBody.length).toBeGreaterThan(0);
expect(afterBody).not.toContain(taskFromA);
expect(afterBody).not.toContain(taskFromB);
console.log('[SetupMigrate] Remote upgraded to encrypted (no plaintext titles)');
// B still holds both tasks (nothing lost during the upgrade).
await expect(pageB.locator(`task:has-text("${taskFromA}")`).first()).toBeVisible();
await expect(pageB.locator(`task:has-text("${taskFromB}")`).first()).toBeVisible();
console.log('[SetupMigrate] ✓ Data preserved and remote encrypted');
await closeContextsSafely(contextA, contextB);
});
});

View file

@ -40,6 +40,7 @@
<mat-form-field appearance="outline">
<mat-label>{{ T.F.SYNC.FORM.L_ENCRYPTION_PASSWORD | translate }}</mat-label>
<input
class="e2e-setup-encrypt-password"
matInput
[type]="showPassword() ? 'text' : 'password'"
[(ngModel)]="password"
@ -64,6 +65,7 @@
<mat-form-field appearance="outline">
<mat-label>{{ textKeys.L_CONFIRM_PASSWORD | translate }}</mat-label>
<input
class="e2e-setup-encrypt-confirm-password"
matInput
[type]="showPassword() ? 'text' : 'password'"
[(ngModel)]="confirmPassword"
@ -82,15 +84,30 @@
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (providerType === 'file-based') {
<!-- E2EE is optional here, so setup must be skippable (the modal is
disableClose, so this is the only non-encrypting way out). -->
<button
class="e2e-setup-encrypt-skip"
mat-button
type="button"
[disabled]="isLoading()"
(click)="cancel()"
>
{{ textKeys.SETUP_SKIP_ENCRYPTION_BTN | translate }}
</button>
} @else {
<button
mat-button
type="button"
[disabled]="isLoading()"
(click)="disableSuperSync()"
>
{{ textKeys.SETUP_DISABLE_SUPERSYNC_BTN | translate }}
</button>
}
<button
mat-button
type="button"
[disabled]="isLoading()"
(click)="disableSuperSync()"
>
{{ textKeys.SETUP_DISABLE_SUPERSYNC_BTN | translate }}
</button>
<button
class="e2e-setup-encrypt-submit"
mat-flat-button
color="primary"
type="button"

View file

@ -1,4 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
@ -353,4 +354,80 @@ describe('DialogEnableEncryptionComponent', () => {
expect(mockDialogRef.close).toHaveBeenCalledWith({ success: false });
});
});
// Collect-only mode (first-time file-based setup): confirm() returns the
// password to the caller and performs NO side effect — no upload, no config
// write. The caller persists it as part of the sync config.
describe('collectPasswordOnly mode', () => {
beforeEach(() => {
mockProviderManager.getActiveProvider.and.returnValue({
id: SyncProviderId.WebDAV,
} as any);
createComponent({
initialSetup: true,
providerType: 'file-based',
collectPasswordOnly: true,
});
});
it('returns the entered password and calls no encryption service', async () => {
component.password = 'password123';
component.confirmPassword = 'password123';
await component.confirm();
expect(mockDialogRef.close).toHaveBeenCalledWith({
success: true,
password: 'password123',
});
expect(mockFileBasedEncryptionService.enableEncryption).not.toHaveBeenCalled();
expect(mockEncryptionToggleService.enableEncryption).not.toHaveBeenCalled();
});
it('does nothing when the password is invalid', async () => {
component.password = 'short';
component.confirmPassword = 'short';
await component.confirm();
expect(mockDialogRef.close).not.toHaveBeenCalled();
});
});
// The initialSetup modal is disableClose. For file-based providers E2EE is
// optional, so the secondary action must be a Skip (cancel) — NOT the
// SuperSync-only "disable sync" — otherwise the user would be trapped.
describe('initialSetup secondary action', () => {
const secondaryButton = (): HTMLButtonElement =>
fixture.debugElement.queryAll(By.css('mat-dialog-actions button'))[0]
.nativeElement as HTMLButtonElement;
it('file-based: secondary action skips via cancel(), never disableSuperSync()', () => {
mockProviderManager.getActiveProvider.and.returnValue({
id: SyncProviderId.WebDAV,
} as any);
createComponent({
initialSetup: true,
providerType: 'file-based',
collectPasswordOnly: true,
});
spyOn(component, 'cancel').and.callThrough();
spyOn(component, 'disableSuperSync');
secondaryButton().click();
expect(component.cancel).toHaveBeenCalledTimes(1);
expect(component.disableSuperSync).not.toHaveBeenCalled();
expect(mockDialogRef.close).toHaveBeenCalledWith({ success: false });
});
it('supersync: secondary action is disableSuperSync()', () => {
createComponent({ initialSetup: true, providerType: 'supersync' });
spyOn(component, 'disableSuperSync').and.callThrough();
secondaryButton().click();
expect(component.disableSuperSync).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -29,10 +29,21 @@ export interface EnableEncryptionDialogData {
encryptKey?: string;
providerType?: 'supersync' | 'file-based';
initialSetup?: boolean;
/**
* Collect-only mode: `confirm()` returns the entered password via
* `EnableEncryptionResult.password` and performs NO side effect (no upload,
* no config write). Used during first-time file-based setup so the key can be
* persisted atomically with the sync config the normal first sync then
* encrypts from the first op, with no separate snapshot-overwrite and no
* plaintext-upload race. See `DialogSyncCfgComponent.save()`.
*/
collectPasswordOnly?: boolean;
}
export interface EnableEncryptionResult {
success: boolean;
/** Set only in `collectPasswordOnly` mode — the password the user entered. */
password?: string;
}
@Component({
@ -78,6 +89,7 @@ export class DialogEnableEncryptionComponent {
errorReason = signal<string | null>(null);
showPassword = signal(false);
initialSetup: boolean = this._data?.initialSetup || false;
collectPasswordOnly: boolean = this._data?.collectPasswordOnly || false;
providerType: 'supersync' | 'file-based' = this._data?.providerType || 'supersync';
textKeys: Record<string, string> =
this.providerType === 'file-based'
@ -141,6 +153,18 @@ export class DialogEnableEncryptionComponent {
return;
}
// Collect-only mode: hand the password back to the caller and do nothing
// else. The caller (first-time file-based setup) persists it as part of the
// sync config, so encryption is applied by the normal sync flow — no upload
// and no config mutation happen here.
if (this.collectPasswordOnly) {
const password = this.password;
this.password = '';
this.confirmPassword = '';
this._matDialogRef.close({ success: true, password });
return;
}
this.isLoading.set(true);
try {

View file

@ -63,6 +63,10 @@ describe('DialogSyncCfgComponent', () => {
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
// Default: the file-based setup encryption dialog is dismissed/skipped.
mockMatDialog.open.and.returnValue({
afterClosed: () => of(undefined),
} as any);
TestBed.configureTestingModule({
imports: [
@ -257,6 +261,124 @@ describe('DialogSyncCfgComponent', () => {
});
});
describe('save() — file-based pre-upload encryption (collect password)', () => {
const setOnline = (isOnline: boolean): void => {
spyOnProperty(navigator, 'onLine', 'get').and.returnValue(isOnline);
};
const mockDialogResult = (result: unknown): void => {
mockMatDialog.open.and.returnValue({ afterClosed: () => of(result) } as any);
};
beforeEach(() => {
mockProviderManager.getProviderById.and.resolveTo({
id: SyncProviderId.WebDAV,
isReady: jasmine.createSpy('isReady').and.resolveTo(true),
} as any);
mockSyncWrapperService.configuredAuthForSyncProviderIfNecessary.and.resolveTo({
wasConfigured: false,
} as any);
});
const setFreshWebdavCfg = (overrides: Partial<SyncConfig> = {}): void => {
(component as any)._tmpUpdatedCfg = {
...(component as any)._tmpUpdatedCfg,
syncProvider: SyncProviderId.WebDAV,
isEnabled: true,
isEncryptionEnabled: false,
_isInitialSetup: true,
...overrides,
};
};
const savedConfig = (): SyncConfig =>
mockSyncConfigService.updateSettingsFromForm.calls.mostRecent()
.args[0] as SyncConfig;
it('persists the entered key + isEncryptionEnabled in the SAME config save (first sync encrypts)', async () => {
setOnline(true);
mockDialogResult({ success: true, password: 'hunter2-secret' });
setFreshWebdavCfg();
await component.save();
expect(mockMatDialog.open).toHaveBeenCalledTimes(1);
const cfg = savedConfig();
expect(cfg.encryptKey).toBe('hunter2-secret');
expect(cfg.isEncryptionEnabled).toBeTrue();
// Normal setup sync still runs — encrypted via config, no separate upload.
expect(mockSyncWrapperService.sync).toHaveBeenCalledOnceWith(true);
});
it('saves without encryption when the user skips the prompt', async () => {
setOnline(true);
mockDialogResult({ success: false });
setFreshWebdavCfg();
await component.save();
expect(mockMatDialog.open).toHaveBeenCalledTimes(1);
const cfg = savedConfig();
expect(cfg.encryptKey).toBe('');
expect(cfg.isEncryptionEnabled).toBeFalse();
expect(mockSyncWrapperService.sync).toHaveBeenCalledOnceWith(true);
});
it('does NOT prompt when re-saving an already-configured provider (not a fresh setup)', async () => {
setOnline(true);
mockDialogResult({ success: true, password: 'unused' });
setFreshWebdavCfg({ _isInitialSetup: false } as any);
await component.save();
expect(mockMatDialog.open).not.toHaveBeenCalled();
expect(savedConfig().isEncryptionEnabled).toBeFalse();
});
it('does NOT prompt when encryption is already enabled', async () => {
setOnline(true);
mockDialogResult({ success: true, password: 'unused' });
setFreshWebdavCfg({ isEncryptionEnabled: true });
await component.save();
expect(mockMatDialog.open).not.toHaveBeenCalled();
});
it('does NOT prompt for a non-file-based provider (SuperSync)', async () => {
setOnline(true);
mockDialogResult({ success: true, password: 'unused' });
mockProviderManager.getProviderById.and.resolveTo({
id: SyncProviderId.SuperSync,
isReady: jasmine.createSpy('isReady').and.resolveTo(true),
} as any);
setFreshWebdavCfg({ syncProvider: SyncProviderId.SuperSync });
await component.save();
expect(mockMatDialog.open).not.toHaveBeenCalled();
// SuperSync keeps its separate post-setup prompt path.
expect(
mockSyncWrapperService.markPromptEncryptionAfterSetupSync,
).toHaveBeenCalledTimes(1);
});
it('offers the prompt even offline (key is config, applied on the next sync)', async () => {
setOnline(false);
mockDialogResult({ success: true, password: 'offline-pw' });
setFreshWebdavCfg();
await component.save();
expect(mockMatDialog.open).toHaveBeenCalledTimes(1);
const cfg = savedConfig();
expect(cfg.encryptKey).toBe('offline-pw');
expect(cfg.isEncryptionEnabled).toBeTrue();
// Offline → no immediate sync, but encryption is already persisted.
expect(mockSyncWrapperService.sync).not.toHaveBeenCalled();
});
});
describe('save() — provider without auth requirement', () => {
it('should close dialog and save config for WebDAV (no getAuthHelper)', async () => {
const webdavProvider = {

View file

@ -32,8 +32,10 @@ import {
} from '../../../op-log/sync-providers/provider.const';
import { SyncConfigService } from '../sync-config.service';
import { SyncWrapperService } from '../sync-wrapper.service';
import { firstValueFrom } from 'rxjs';
import { first, skip } from 'rxjs/operators';
import { toSyncProviderId } from '../../../op-log/sync-exports';
import { isFileBasedProviderId } from '../../../op-log/sync/operation-sync.util';
import { SyncLog } from '../../../core/log';
import { SyncProviderManager } from '../../../op-log/sync-providers/provider-manager.service';
@ -630,6 +632,28 @@ export class DialogSyncCfgComponent implements AfterViewInit {
}
}
// File-based providers support OPTIONAL E2EE but (unlike SuperSync) have no
// mandatory-encryption upload guard. So instead of prompting AFTER the first
// sync (which would already have shipped plaintext, and would race the auto
// "just enabled" sync), offer to set the encryption password here and persist
// it as part of THIS config save. The key lands in the provider's privateCfg
// and `isEncryptionEnabled` in the global config atomically with `isEnabled`,
// so the normal first sync encrypts from the very first op — no separate
// snapshot-overwrite, no plaintext-upload window. Skipping keeps today's
// unencrypted behavior. No network needed, so this also covers offline setup.
if (
_isInitialSetup &&
providerId &&
isFileBasedProviderId(providerId) &&
!configToSave.isEncryptionEnabled
) {
const encryptKey = await this._collectFileBasedSetupEncryptionKey();
if (encryptKey) {
configToSave.encryptKey = encryptKey;
configToSave.isEncryptionEnabled = true;
}
}
await this.syncConfigService.updateSettingsFromForm(configToSave as SyncConfig, true);
this._matDialogRef.close();
@ -647,6 +671,28 @@ export class DialogSyncCfgComponent implements AfterViewInit {
}
}
/**
* Open the encryption dialog in collect-only mode to gather an optional
* setup password for a file-based provider. Returns the password, or `null`
* if the user skipped. Performs no side effect the caller persists the key
* as part of the sync config so the normal first sync encrypts from op #1.
*/
private async _collectFileBasedSetupEncryptionKey(): Promise<string | null> {
const { DialogEnableEncryptionComponent } =
await import('../dialog-enable-encryption/dialog-enable-encryption.component');
const dialogRef = this._matDialog.open(DialogEnableEncryptionComponent, {
width: '450px',
disableClose: true,
data: {
providerType: 'file-based',
initialSetup: true,
collectPasswordOnly: true,
},
});
const result = await firstValueFrom(dialogRef.afterClosed());
return result?.success && result.password ? result.password : null;
}
updateTmpCfg(cfg: SyncConfig & { _isInitialSetup?: boolean }): void {
// Use Object.assign to preserve the object reference for Formly
// This ensures Formly detects changes to the model

View file

@ -1,5 +1,6 @@
import {
isFileBasedProvider,
isFileBasedProviderId,
isOperationSyncCapable,
syncOpToOperation,
} from './operation-sync.util';
@ -84,6 +85,23 @@ describe('operation-sync utility', () => {
});
});
describe('isFileBasedProviderId', () => {
it('agrees with isFileBasedProvider for every SyncProviderId member', () => {
const allIds = Object.values(SyncProviderId) as SyncProviderId[];
for (const id of allIds) {
const provider = { id } as unknown as SyncProviderBase<SyncProviderId>;
expect(isFileBasedProviderId(id))
.withContext(`isFileBasedProviderId mismatch for SyncProviderId.${id}`)
.toBe(isFileBasedProvider(provider));
}
});
it('returns true for WebDAV and false for SuperSync', () => {
expect(isFileBasedProviderId(SyncProviderId.WebDAV)).toBeTrue();
expect(isFileBasedProviderId(SyncProviderId.SuperSync)).toBeFalse();
});
});
describe('isOperationSyncCapable', () => {
it('should return true for provider with supportsOperationSync = true', () => {
const provider = {

View file

@ -45,7 +45,16 @@ export const isOperationSyncCapable = (
export const isFileBasedProvider = (
provider: SyncProviderBase<SyncProviderId>,
): boolean => {
return FILE_BASED_PROVIDER_IDS.has(provider.id);
return isFileBasedProviderId(provider.id);
};
/**
* Id-based sibling of `isFileBasedProvider` for callers that only have the
* provider id, not a resolved provider instance (e.g. the sync-config dialog
* deciding whether to offer pre-upload encryption during first-time setup).
*/
export const isFileBasedProviderId = (id: SyncProviderId): boolean => {
return FILE_BASED_PROVIDER_IDS.has(id);
};
const VALID_OP_TYPES = new Set<string>(Object.values(OpType));

View file

@ -1409,6 +1409,7 @@ const T = {
ENABLE_ENCRYPTION_WARNING: 'F.SYNC.FORM.FILE_BASED.ENABLE_ENCRYPTION_WARNING',
ENABLE_ENCRYPTION_WHAT_HAPPENS:
'F.SYNC.FORM.FILE_BASED.ENABLE_ENCRYPTION_WHAT_HAPPENS',
ENCRYPTION_SETUP_INTRO: 'F.SYNC.FORM.FILE_BASED.ENCRYPTION_SETUP_INTRO',
L_CHANGE_ENCRYPTION_PASSWORD:
'F.SYNC.FORM.FILE_BASED.L_CHANGE_ENCRYPTION_PASSWORD',
L_CONFIRM_PASSWORD: 'F.SYNC.FORM.FILE_BASED.L_CONFIRM_PASSWORD',
@ -1417,6 +1418,11 @@ const T = {
PASSWORD_MIN_LENGTH: 'F.SYNC.FORM.FILE_BASED.PASSWORD_MIN_LENGTH',
PASSWORDS_DONT_MATCH: 'F.SYNC.FORM.FILE_BASED.PASSWORDS_DONT_MATCH',
REMOVE_ENCRYPTION_WARNING: 'F.SYNC.FORM.FILE_BASED.REMOVE_ENCRYPTION_WARNING',
SETUP_ENCRYPTION_BTN: 'F.SYNC.FORM.FILE_BASED.SETUP_ENCRYPTION_BTN',
SETUP_ENCRYPTION_PASSWORD_WARNING:
'F.SYNC.FORM.FILE_BASED.SETUP_ENCRYPTION_PASSWORD_WARNING',
SETUP_ENCRYPTION_TITLE: 'F.SYNC.FORM.FILE_BASED.SETUP_ENCRYPTION_TITLE',
SETUP_SKIP_ENCRYPTION_BTN: 'F.SYNC.FORM.FILE_BASED.SETUP_SKIP_ENCRYPTION_BTN',
},
GOOGLE: {
L_SYNC_FILE_NAME: 'F.SYNC.FORM.GOOGLE.L_SYNC_FILE_NAME',

View file

@ -1379,13 +1379,18 @@
"ENABLE_ENCRYPTION_TITLE": "Enable Encryption?",
"ENABLE_ENCRYPTION_WARNING": "WARNING: This will re-upload your data with encryption. If you forget your encryption password, your data cannot be recovered!",
"ENABLE_ENCRYPTION_WHAT_HAPPENS": "What will happen:",
"ENCRYPTION_SETUP_INTRO": "<strong>Set a password to encrypt your data before it is uploaded</strong>, so it never leaves this device unencrypted. This is optional — you can also enable or change it later.",
"L_CHANGE_ENCRYPTION_PASSWORD": "Change Encryption Password",
"L_CONFIRM_PASSWORD": "Confirm Password",
"L_NEW_PASSWORD": "New Encryption Password",
"L_REMOVE_ENCRYPTION": "Or Remove Encryption",
"PASSWORD_MIN_LENGTH": "Password must be at least 8 characters",
"PASSWORDS_DONT_MATCH": "Passwords do not match",
"REMOVE_ENCRYPTION_WARNING": "Removing encryption will re-upload your data without encryption. All devices will need to reconfigure."
"REMOVE_ENCRYPTION_WARNING": "Removing encryption will re-upload your data without encryption. All devices will need to reconfigure.",
"SETUP_ENCRYPTION_BTN": "Encrypt & Continue",
"SETUP_ENCRYPTION_PASSWORD_WARNING": "Use the same password on all devices. If you forget it, encrypted data cannot be recovered.",
"SETUP_ENCRYPTION_TITLE": "Encrypt before first upload?",
"SETUP_SKIP_ENCRYPTION_BTN": "Skip"
},
"GOOGLE": {
"L_SYNC_FILE_NAME": "Sync File Name"