[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
764
infrastructure/services/cloudSync/authMethods.ts
Normal file
764
infrastructure/services/cloudSync/authMethods.ts
Normal file
@@ -0,0 +1,764 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
|
||||
import { EncryptionService } from '../EncryptionService';
|
||||
import { createAdapter, type CloudAdapter } from '../adapters';
|
||||
import type GitHubAdapter from '../adapters/GitHubAdapter';
|
||||
import type GoogleDriveAdapter from '../adapters/GoogleDriveAdapter';
|
||||
import type OneDriveAdapter from '../adapters/OneDriveAdapter';
|
||||
import { createSyncedFileSignature as createSyncedFileSignatureCore } from '../syncSignature.js';
|
||||
import { decideRemoteChanged } from '../syncAnchorDecision.js';
|
||||
import type {
|
||||
CloudProvider,
|
||||
OAuthTokens,
|
||||
ProviderAccount,
|
||||
ProviderConnection,
|
||||
S3Config,
|
||||
SyncedFile,
|
||||
SyncPayload,
|
||||
WebDAVConfig,
|
||||
} from '../../../domain/sync';
|
||||
import { normalizeDurablePluginSyncCredentialRef } from '../../../domain/sync';
|
||||
import { isPluginCloudProviderId } from '../../../domain/cloudProviderIds';
|
||||
import type {
|
||||
ProviderSyncAnchor,
|
||||
StartProviderAuthResult,
|
||||
} from '../CloudSyncManager';
|
||||
import {
|
||||
registerPluginProviderIdImpl,
|
||||
unregisterPluginProviderIdImpl,
|
||||
} from './stateAndSecurityMethods';
|
||||
|
||||
const SYNC_REMOTE_ANCHOR_STORAGE_KEY = 'netcatty_sync_remote_anchor_v1';
|
||||
|
||||
export function clearProviderMergeStateImpl(this: any, provider: CloudProvider): void {
|
||||
this.removeFromStorage(this.syncBaseKey(provider));
|
||||
this.removeFromStorage(this.convergentProviderBaselineKey(provider));
|
||||
this.clearSyncAnchor(provider);
|
||||
}
|
||||
|
||||
export async function startProviderAuthImpl(this: any,
|
||||
provider: CloudProvider,
|
||||
redirectUri?: string
|
||||
): Promise<StartProviderAuthResult> {
|
||||
if (provider === 'webdav' || provider === 's3') {
|
||||
throw new Error('Provider requires manual configuration');
|
||||
}
|
||||
const authAttemptId = ++this.providerAuthAttemptSeq[provider];
|
||||
this.providerAuthRestoreState[provider] = {
|
||||
attemptId: authAttemptId,
|
||||
connection: { ...this.state.providers[provider] },
|
||||
adapter: this.adapters.get(provider) ?? null,
|
||||
};
|
||||
const adapter = await createAdapter(provider);
|
||||
if (!this.isActiveAuthAttempt(provider, authAttemptId)) {
|
||||
throw new Error(`${provider} auth superseded`);
|
||||
}
|
||||
this.adapters.set(provider, adapter);
|
||||
|
||||
this.updateProviderStatus(provider, 'connecting');
|
||||
try {
|
||||
if (provider === 'github') {
|
||||
// GitHub uses Device Flow
|
||||
const ghAdapter = adapter as GitHubAdapter;
|
||||
const deviceFlow = await ghAdapter.startAuth();
|
||||
|
||||
return {
|
||||
type: 'device_code',
|
||||
data: { ...deviceFlow, authAttemptId },
|
||||
};
|
||||
} else {
|
||||
// Google and OneDrive use PKCE with redirect
|
||||
if (!redirectUri) {
|
||||
throw new Error(
|
||||
`startProviderAuth('${provider}') requires a redirectUri — ` +
|
||||
'call prepareOAuthCallback on the bridge first and pass its redirectUri through.'
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === 'google') {
|
||||
const gdAdapter = adapter as GoogleDriveAdapter;
|
||||
const url = await gdAdapter.startAuth(redirectUri);
|
||||
return { type: 'url', data: { url, redirectUri, authAttemptId } };
|
||||
} else {
|
||||
const odAdapter = adapter as OneDriveAdapter;
|
||||
const url = await odAdapter.startAuth(redirectUri);
|
||||
return { type: 'url', data: { url, redirectUri, authAttemptId } };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.isActiveAuthAttempt(provider, authAttemptId)) {
|
||||
throw error;
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[CloudSync] ${provider} connect failed`, {
|
||||
error: errorMessage,
|
||||
});
|
||||
this.updateProviderStatus(provider, 'error', errorMessage);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeGitHubAuthImpl(this: any,
|
||||
deviceCode: string,
|
||||
interval: number,
|
||||
expiresAt: number,
|
||||
onPending?: () => void,
|
||||
signal?: AbortSignal,
|
||||
authAttemptId?: number
|
||||
): Promise<void> {
|
||||
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
|
||||
throw new Error('github auth superseded');
|
||||
}
|
||||
const adapter = this.adapters.get('github');
|
||||
if (!adapter) {
|
||||
throw new Error('GitHub adapter not initialized');
|
||||
}
|
||||
|
||||
const ghAdapter = adapter as GitHubAdapter;
|
||||
|
||||
try {
|
||||
// Snapshot the prior account BEFORE we overwrite providers[provider].
|
||||
// Used as a fallback for the same-account comparison when the persisted
|
||||
// accountId key is absent (e.g., first re-auth after upgrading to this
|
||||
// version, where the key didn't exist yet).
|
||||
const previousAccount = this.state.providers.github?.account;
|
||||
|
||||
const tokens = await ghAdapter.completeAuth(deviceCode, interval, expiresAt, onPending, signal);
|
||||
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
|
||||
throw new Error('github auth superseded');
|
||||
}
|
||||
const resourceId = await ghAdapter.initializeSync(signal);
|
||||
|
||||
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
|
||||
throw new Error('github auth superseded');
|
||||
}
|
||||
|
||||
++this.providerDecryptSeq.github;
|
||||
this.state.providers.github = {
|
||||
...this.state.providers.github,
|
||||
status: 'connected',
|
||||
tokens,
|
||||
account: ghAdapter.accountInfo || undefined,
|
||||
};
|
||||
|
||||
if (resourceId) {
|
||||
this.state.providers.github.resourceId = resourceId;
|
||||
}
|
||||
|
||||
await this.saveProviderConnection('github', this.state.providers.github, authAttemptId);
|
||||
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
|
||||
throw new Error('github auth superseded');
|
||||
}
|
||||
|
||||
// Only clear the merge base if the authenticated account identity differs
|
||||
// from the previously-stored one. See notes in completePKCEAuth.
|
||||
const newId = ghAdapter.accountInfo?.id ?? null;
|
||||
const previousId = this.loadProviderAccountId('github') ?? previousAccount?.id ?? null;
|
||||
const sameAccount = newId !== null && previousId !== null && newId === previousId;
|
||||
if (!sameAccount) {
|
||||
clearProviderMergeStateImpl.call(this, 'github');
|
||||
}
|
||||
if (newId) {
|
||||
this.saveProviderAccountId('github', newId);
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider: 'github',
|
||||
account: ghAdapter.accountInfo!,
|
||||
});
|
||||
this.providerAuthRestoreState.github = null;
|
||||
} catch (error) {
|
||||
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('auth superseded')) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
this.resetProviderStatus('github', authAttemptId);
|
||||
throw error;
|
||||
}
|
||||
this.resetProviderStatus('github', authAttemptId);
|
||||
this.setProviderError('github', String(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function completePKCEAuthImpl(this: any,
|
||||
provider: 'google' | 'onedrive',
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
authAttemptId?: number
|
||||
): Promise<void> {
|
||||
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
|
||||
throw new Error(`${provider} auth superseded`);
|
||||
}
|
||||
const adapter = this.adapters.get(provider);
|
||||
if (!adapter) {
|
||||
throw new Error(`${provider} adapter not initialized`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Snapshot the prior account BEFORE we overwrite providers[provider].
|
||||
// Used as a fallback for the same-account comparison when the persisted
|
||||
// accountId key is absent (e.g., first re-auth after upgrading to this
|
||||
// version, where the key didn't exist yet).
|
||||
const previousAccount = this.state.providers[provider]?.account;
|
||||
|
||||
let tokens: OAuthTokens;
|
||||
let account;
|
||||
|
||||
if (provider === 'google') {
|
||||
const gdAdapter = adapter as GoogleDriveAdapter;
|
||||
tokens = await gdAdapter.completeAuth(code, redirectUri);
|
||||
account = gdAdapter.accountInfo;
|
||||
} else {
|
||||
const odAdapter = adapter as OneDriveAdapter;
|
||||
tokens = await odAdapter.completeAuth(code, redirectUri);
|
||||
account = odAdapter.accountInfo;
|
||||
}
|
||||
|
||||
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
|
||||
throw new Error(`${provider} auth superseded`);
|
||||
}
|
||||
|
||||
const resourceId = await adapter.initializeSync();
|
||||
|
||||
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
|
||||
throw new Error(`${provider} auth superseded`);
|
||||
}
|
||||
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
...this.state.providers[provider],
|
||||
status: 'connected',
|
||||
tokens,
|
||||
account: account || undefined,
|
||||
};
|
||||
|
||||
if (resourceId) {
|
||||
this.state.providers[provider].resourceId = resourceId;
|
||||
}
|
||||
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider], authAttemptId);
|
||||
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
|
||||
throw new Error(`${provider} auth superseded`);
|
||||
}
|
||||
|
||||
// Only clear the merge base if the authenticated account identity differs
|
||||
// from the previously-stored one. Same-account re-auth preserves the base
|
||||
// so the next sync computes correct local-deletions instead of treating
|
||||
// it as "first sync" and resurrecting zombie entries via null-base union.
|
||||
const newId = account?.id ?? null;
|
||||
const previousId = this.loadProviderAccountId(provider) ?? previousAccount?.id ?? null;
|
||||
const sameAccount = newId !== null && previousId !== null && newId === previousId;
|
||||
if (!sameAccount) {
|
||||
clearProviderMergeStateImpl.call(this, provider);
|
||||
}
|
||||
if (newId) {
|
||||
this.saveProviderAccountId(provider, newId);
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider,
|
||||
account: account!,
|
||||
});
|
||||
this.providerAuthRestoreState[provider] = null;
|
||||
} catch (error) {
|
||||
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('auth superseded')) {
|
||||
throw error;
|
||||
}
|
||||
this.resetProviderStatus(provider, authAttemptId);
|
||||
this.setProviderError(provider, String(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectConfigProviderImpl(this: any,
|
||||
provider: 'webdav' | 's3',
|
||||
config: WebDAVConfig | S3Config
|
||||
): Promise<void> {
|
||||
const adapter = await createAdapter(provider, undefined, undefined, config);
|
||||
this.adapters.set(provider, adapter);
|
||||
this.updateProviderStatus(provider, 'connecting');
|
||||
|
||||
try {
|
||||
const resourceId = await adapter.initializeSync();
|
||||
const account = adapter.accountInfo || this.buildAccountFromConfig(provider, config);
|
||||
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
provider,
|
||||
status: 'connected',
|
||||
config,
|
||||
account,
|
||||
resourceId: resourceId || undefined,
|
||||
};
|
||||
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
// Clear all trusted merge state when changing endpoint or bucket.
|
||||
clearProviderMergeStateImpl.call(this, provider);
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider,
|
||||
account,
|
||||
});
|
||||
} catch (error) {
|
||||
this.updateProviderStatus(provider, 'error', String(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a namespaced plugin sync Provider. Configuration is opaque plugin-
|
||||
* owned JSON; Netcatty still owns encryption and only forwards encrypted objects.
|
||||
*/
|
||||
export async function connectPluginProviderImpl(
|
||||
this: any,
|
||||
providerId: string,
|
||||
configuration: unknown = {},
|
||||
credential?: unknown,
|
||||
): Promise<void> {
|
||||
if (!isPluginCloudProviderId(providerId)) {
|
||||
throw new Error(`Invalid plugin sync provider ID: ${providerId}`);
|
||||
}
|
||||
// Initialize sequence counters before the first status write so cross-window
|
||||
// handlers never see NaN and discard every later update.
|
||||
if (this.providerWriteSeq[providerId] == null || Number.isNaN(this.providerWriteSeq[providerId])) {
|
||||
this.providerWriteSeq[providerId] = 0;
|
||||
}
|
||||
if (this.providerDecryptSeq[providerId] == null || Number.isNaN(this.providerDecryptSeq[providerId])) {
|
||||
this.providerDecryptSeq[providerId] = 0;
|
||||
}
|
||||
if (this.providerDecrypted[providerId] == null) this.providerDecrypted[providerId] = true;
|
||||
if (this.providerAuthAttemptSeq[providerId] == null || Number.isNaN(this.providerAuthAttemptSeq[providerId])) {
|
||||
this.providerAuthAttemptSeq[providerId] = 0;
|
||||
}
|
||||
this.updateProviderStatus(providerId, 'connecting');
|
||||
try {
|
||||
const createPluginStorage = async (id: string) => {
|
||||
if (typeof this.createPluginStorage === 'function') {
|
||||
return this.createPluginStorage(id, {
|
||||
provider: id,
|
||||
status: 'connecting',
|
||||
config: configuration as ProviderConnection['config'],
|
||||
});
|
||||
}
|
||||
const { createPluginSyncIpcHost, isPluginSyncIpcAvailable } = await import(
|
||||
'../adapters/pluginSyncIpcHost'
|
||||
);
|
||||
const { createPluginSyncObjectStorage } = await import('../adapters/pluginSyncObjectStorage');
|
||||
if (!isPluginSyncIpcAvailable()) {
|
||||
throw new Error(`Plugin sync provider ${id} is unavailable (plugin host not enabled)`);
|
||||
}
|
||||
return createPluginSyncObjectStorage({
|
||||
providerId: id,
|
||||
host: createPluginSyncIpcHost(),
|
||||
configuration,
|
||||
credential: credential as import('../adapters/pluginSyncObjectStorage').PluginSyncCredentialRef | undefined,
|
||||
});
|
||||
};
|
||||
const adapter = await createAdapter(
|
||||
providerId,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ createPluginStorage },
|
||||
);
|
||||
// Only cache after initializeSync succeeds so a rejected reconnect cannot
|
||||
// leave a half-authenticated adapter pinned for later auto-sync.
|
||||
let resourceId: string | null;
|
||||
try {
|
||||
resourceId = await adapter.initializeSync();
|
||||
} catch (error) {
|
||||
try { adapter.signOut(); } catch { /* ignore */ }
|
||||
this.adapters.delete(providerId);
|
||||
throw error;
|
||||
}
|
||||
this.adapters.set(providerId, adapter);
|
||||
const account = adapter.accountInfo ?? { id: providerId };
|
||||
if (this.providerDecryptSeq[providerId] == null) this.providerDecryptSeq[providerId] = 0;
|
||||
if (this.providerWriteSeq[providerId] == null) this.providerWriteSeq[providerId] = 0;
|
||||
if (this.providerDecrypted[providerId] == null) this.providerDecrypted[providerId] = true;
|
||||
++this.providerDecryptSeq[providerId];
|
||||
// Capture pre-connect identity before overwriting state.
|
||||
const previous = this.state.providers[providerId];
|
||||
const previousAccountId = previous?.account?.id ?? null;
|
||||
const previousResource = previous?.resourceId ?? null;
|
||||
const configFingerprint = (value: unknown): string => {
|
||||
const normalize = (input: unknown): unknown => {
|
||||
if (Array.isArray(input)) return input.map(normalize);
|
||||
if (input && typeof input === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.keys(input as Record<string, unknown>)
|
||||
.sort()
|
||||
.map((key) => [key, normalize((input as Record<string, unknown>)[key])]),
|
||||
);
|
||||
}
|
||||
return input;
|
||||
};
|
||||
try {
|
||||
return JSON.stringify(normalize(value ?? null));
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
const previousConfigFp = configFingerprint(previous?.config ?? null);
|
||||
const nextAccountId = account?.id ?? null;
|
||||
const nextResource = resourceId || null;
|
||||
const nextConfigFp = configFingerprint(configuration ?? null);
|
||||
this.state.providers[providerId] = {
|
||||
provider: providerId,
|
||||
status: 'connected',
|
||||
config: configuration as ProviderConnection['config'],
|
||||
...((() => {
|
||||
const ref = normalizeDurablePluginSyncCredentialRef(credential);
|
||||
return ref ? { credential: ref } : {};
|
||||
})()),
|
||||
account,
|
||||
resourceId: resourceId || undefined,
|
||||
};
|
||||
registerPluginProviderIdImpl.call(this, providerId);
|
||||
await this.saveProviderConnection(providerId, this.state.providers[providerId]);
|
||||
// Preserve merge base / anchors when reconnecting the same account+resource
|
||||
// and configuration; clear when backend identity or config changes.
|
||||
const previousCredentialFp = `${previous?.credential?.kind ?? ''}\0${previous?.credential?.id ?? ''}\0${previous?.credential?.key ?? ''}`;
|
||||
const nextCredentialFp = `${this.state.providers[providerId].credential?.kind ?? ''}\0${this.state.providers[providerId].credential?.id ?? ''}\0${this.state.providers[providerId].credential?.key ?? ''}`;
|
||||
if (
|
||||
previousAccountId !== nextAccountId
|
||||
|| previousResource !== nextResource
|
||||
|| previousConfigFp !== nextConfigFp
|
||||
|| previousCredentialFp !== nextCredentialFp
|
||||
) {
|
||||
clearProviderMergeStateImpl.call(this, providerId);
|
||||
}
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider: providerId,
|
||||
account,
|
||||
});
|
||||
this.notifyStateChange();
|
||||
} catch (error) {
|
||||
this.updateProviderStatus(providerId, 'error', String(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function resetProviderStatusImpl(this: any,provider: CloudProvider, authAttemptId?: number): void {
|
||||
const restoreState = this.providerAuthRestoreState[provider];
|
||||
if (
|
||||
authAttemptId != null &&
|
||||
restoreState &&
|
||||
restoreState.attemptId !== authAttemptId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoreState) {
|
||||
this.state.providers[provider] = { ...restoreState.connection };
|
||||
if (restoreState.adapter) {
|
||||
this.adapters.set(provider, restoreState.adapter);
|
||||
} else {
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
this.notifyStateChange();
|
||||
} else if (this.state.providers[provider]?.status === 'connecting') {
|
||||
this.updateProviderStatus(provider, 'disconnected');
|
||||
return;
|
||||
}
|
||||
if (!restoreState || authAttemptId == null || restoreState.attemptId === authAttemptId) {
|
||||
this.providerAuthRestoreState[provider] = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setProviderErrorImpl(this: any,provider: CloudProvider, error: string): void {
|
||||
this.updateProviderStatus(provider, 'error', error);
|
||||
}
|
||||
|
||||
export function clearConnectingStatusImpl(this: any,provider: CloudProvider): void {
|
||||
if (this.state.providers[provider]?.status !== 'connecting') {
|
||||
return;
|
||||
}
|
||||
this.updateProviderStatus(provider, 'disconnected');
|
||||
}
|
||||
|
||||
export function clearProviderErrorImpl(this: any,provider: CloudProvider): void {
|
||||
const connection = this.state.providers[provider];
|
||||
if (!connection?.error && connection?.status !== 'error') {
|
||||
return;
|
||||
}
|
||||
this.state.providers[provider] = {
|
||||
...connection,
|
||||
status: connection.status === 'error' ? 'disconnected' : connection.status,
|
||||
error: undefined,
|
||||
};
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
export function cancelProviderAuthAttemptImpl(this: any,provider: CloudProvider, authAttemptId?: number): void {
|
||||
if (
|
||||
authAttemptId != null &&
|
||||
!this.isActiveAuthAttempt(provider, authAttemptId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.resetProviderStatus(provider, authAttemptId);
|
||||
++this.providerAuthAttemptSeq[provider];
|
||||
const restoreState = this.providerAuthRestoreState[provider];
|
||||
if (!restoreState || authAttemptId == null || restoreState.attemptId === authAttemptId) {
|
||||
this.providerAuthRestoreState[provider] = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectProviderImpl(this: any,provider: CloudProvider): Promise<void> {
|
||||
this.cancelProviderAuthAttempt(provider);
|
||||
const adapter = this.adapters.get(provider);
|
||||
if (adapter) {
|
||||
adapter.signOut();
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
provider,
|
||||
status: 'disconnected',
|
||||
};
|
||||
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
// Explicit disconnect removes the dynamic provider from the restart registry.
|
||||
// Missing plugins with preserved config remain registered and are not coerced away.
|
||||
unregisterPluginProviderIdImpl.call(this, provider);
|
||||
// Clear all trusted merge state so a later account/resource cannot reuse
|
||||
// an unrelated snapshot or convergent baseline.
|
||||
clearProviderMergeStateImpl.call(this, provider);
|
||||
this.removeFromStorage(this.providerAccountIdKey(provider));
|
||||
// Drop OS-backed sync secrets so disconnect does not leave reusable passwords.
|
||||
if (isPluginCloudProviderId(provider)) {
|
||||
try {
|
||||
const { deletePluginSyncSecrets } = await import('../adapters/pluginSyncIpcHost');
|
||||
await deletePluginSyncSecrets({ providerId: provider });
|
||||
} catch {
|
||||
/* best-effort; disconnect still succeeds */
|
||||
}
|
||||
}
|
||||
// Reset BLOCKED state if it was present — disconnect implicitly resolves
|
||||
// any pending shrink-block warning since there's no provider to push to.
|
||||
this.exitBlockedState();
|
||||
if (this.state.syncState === 'BLOCKED') {
|
||||
this.state.syncState = 'IDLE';
|
||||
}
|
||||
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
|
||||
}
|
||||
|
||||
export function updateProviderStatusImpl(this: any,
|
||||
provider: CloudProvider,
|
||||
status: ProviderConnection['status'],
|
||||
error?: string
|
||||
): void {
|
||||
// Bump sequence to invalidate any in-flight async decrypt for this provider
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
...this.state.providers[provider],
|
||||
status,
|
||||
error,
|
||||
};
|
||||
this.notifyStateChange(); // Notify UI of status change
|
||||
}
|
||||
|
||||
export function isActiveAuthAttemptImpl(this: any,provider: CloudProvider, authAttemptId: number): boolean {
|
||||
return this.providerAuthAttemptSeq[provider] === authAttemptId;
|
||||
}
|
||||
|
||||
export function buildAccountFromConfigImpl(this: any,
|
||||
provider: 'webdav' | 's3',
|
||||
config: WebDAVConfig | S3Config
|
||||
): ProviderAccount {
|
||||
if (provider === 'webdav') {
|
||||
const endpoint = (config as WebDAVConfig).endpoint;
|
||||
return { id: endpoint, name: endpoint };
|
||||
}
|
||||
const s3 = config as S3Config;
|
||||
return { id: `${s3.bucket}@${s3.endpoint}`, name: `${s3.bucket} (${s3.region})` };
|
||||
}
|
||||
|
||||
export function syncAnchorKeyImpl(this: any,provider: CloudProvider): string {
|
||||
return `${SYNC_REMOTE_ANCHOR_STORAGE_KEY}_${provider}`;
|
||||
}
|
||||
|
||||
export function createSyncedFileSignatureImpl(this: any,syncedFile: SyncedFile | null): Promise<string | null> {
|
||||
return createSyncedFileSignatureCore(syncedFile);
|
||||
}
|
||||
|
||||
export function loadSyncAnchorImpl(this: any,provider: CloudProvider): ProviderSyncAnchor | null {
|
||||
return this.loadFromStorage<ProviderSyncAnchor>(this.syncAnchorKey(provider));
|
||||
}
|
||||
|
||||
export async function saveSyncAnchorImpl(this: any,
|
||||
provider: CloudProvider,
|
||||
syncedFile: SyncedFile | null,
|
||||
resourceId?: string | null,
|
||||
): Promise<void> {
|
||||
this.saveToStorage(this.syncAnchorKey(provider), {
|
||||
signature: await this.createSyncedFileSignature(syncedFile),
|
||||
version: syncedFile?.meta.version ?? 0,
|
||||
updatedAt: syncedFile?.meta.updatedAt ?? 0,
|
||||
deviceId: syncedFile?.meta.deviceId,
|
||||
resourceId: resourceId ?? this.state.providers[provider].resourceId ?? null,
|
||||
observedAt: Date.now(),
|
||||
} satisfies ProviderSyncAnchor);
|
||||
}
|
||||
|
||||
export function clearSyncAnchorImpl(this: any,provider?: CloudProvider): void {
|
||||
if (provider) {
|
||||
this.removeFromStorage(this.syncAnchorKey(provider));
|
||||
return;
|
||||
}
|
||||
const providers = new Set<CloudProvider>([
|
||||
'github', 'google', 'onedrive', 'webdav', 's3',
|
||||
]);
|
||||
for (const id of Object.keys(this.state?.providers ?? {})) {
|
||||
providers.add(id as CloudProvider);
|
||||
}
|
||||
if (typeof this.listRegisteredPluginProviderIds === 'function') {
|
||||
for (const id of this.listRegisteredPluginProviderIds()) {
|
||||
providers.add(id as CloudProvider);
|
||||
}
|
||||
}
|
||||
for (const p of providers) {
|
||||
this.removeFromStorage(this.syncAnchorKey(p));
|
||||
}
|
||||
}
|
||||
|
||||
export async function inspectProviderRemoteStateImpl(this: any,
|
||||
provider: CloudProvider,
|
||||
adapter: CloudAdapter,
|
||||
): Promise<{
|
||||
remoteChanged: boolean;
|
||||
remoteFile: SyncedFile | null;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const remoteFile = await adapter.download();
|
||||
const currentSignature = await this.createSyncedFileSignature(remoteFile);
|
||||
const anchor = this.loadSyncAnchor(provider);
|
||||
const currentResourceId = adapter.resourceId || this.state.providers[provider].resourceId || null;
|
||||
|
||||
const decision = decideRemoteChanged({
|
||||
currentSignature,
|
||||
currentResourceId,
|
||||
anchor,
|
||||
hasRemoteFile: Boolean(remoteFile),
|
||||
});
|
||||
|
||||
return {
|
||||
remoteChanged: decision.remoteChanged,
|
||||
remoteFile,
|
||||
};
|
||||
} catch (error) {
|
||||
// A dead OneDrive refresh token surfaces here during sync preflight,
|
||||
// syncAll preflight, and startup inspection. Clear the stale credentials
|
||||
// so the provider drops to a reconnect state instead of being retried.
|
||||
if (typeof this.handleProviderReauthRequired === 'function') {
|
||||
this.handleProviderReauthRequired(provider, error);
|
||||
}
|
||||
return {
|
||||
remoteChanged: false,
|
||||
remoteFile: null,
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkProviderConflictImpl(this: any,
|
||||
provider: CloudProvider,
|
||||
adapter: CloudAdapter
|
||||
): Promise<{
|
||||
conflict: boolean;
|
||||
remoteFile?: SyncedFile;
|
||||
}> {
|
||||
const inspection = await this.inspectProviderRemoteState(provider, adapter);
|
||||
if (inspection.error) {
|
||||
throw new Error(inspection.error);
|
||||
}
|
||||
return {
|
||||
conflict: inspection.remoteChanged && Boolean(inspection.remoteFile),
|
||||
remoteFile: inspection.remoteFile ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function inspectProviderRemoteImpl(this: any,provider: CloudProvider): Promise<{
|
||||
remoteChanged: boolean;
|
||||
remoteFile: SyncedFile | null;
|
||||
payload: SyncPayload | null;
|
||||
}> {
|
||||
if (this.state.securityState !== 'UNLOCKED' || !this.masterPassword) {
|
||||
throw new Error('Vault is locked');
|
||||
}
|
||||
|
||||
const adapter = await this.getConnectedAdapter(provider);
|
||||
const inspection = await this.inspectProviderRemoteState(provider, adapter);
|
||||
if (inspection.error) {
|
||||
throw new Error(inspection.error);
|
||||
}
|
||||
|
||||
if (!inspection.remoteFile) {
|
||||
return {
|
||||
remoteChanged: inspection.remoteChanged,
|
||||
remoteFile: null,
|
||||
payload: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
remoteChanged: inspection.remoteChanged,
|
||||
remoteFile: inspection.remoteFile,
|
||||
payload: await EncryptionService.decryptPayload(inspection.remoteFile, this.masterPassword),
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitRemoteInspectionImpl(this: any,
|
||||
provider: CloudProvider,
|
||||
remoteFile: SyncedFile,
|
||||
payload: SyncPayload,
|
||||
opts: { recordDownload?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const adapter = await this.getConnectedAdapter(provider);
|
||||
const resourceId = adapter.resourceId || this.state.providers[provider].resourceId || null;
|
||||
if (resourceId && this.state.providers[provider].resourceId !== resourceId) {
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
...this.state.providers[provider],
|
||||
resourceId,
|
||||
};
|
||||
}
|
||||
|
||||
this.state.localVersion = remoteFile.meta.version;
|
||||
this.state.localUpdatedAt = remoteFile.meta.updatedAt;
|
||||
this.state.remoteVersion = remoteFile.meta.version;
|
||||
this.state.remoteUpdatedAt = remoteFile.meta.updatedAt;
|
||||
this.state.providers[provider].lastSync = Date.now();
|
||||
this.state.providers[provider].lastSyncVersion = remoteFile.meta.version;
|
||||
|
||||
await this.saveSyncBase(payload, provider);
|
||||
this.saveSyncConfig();
|
||||
await this.saveSyncAnchor(provider, remoteFile, resourceId);
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
if (opts.recordDownload === true) {
|
||||
this.addSyncHistoryEntry({
|
||||
timestamp: Date.now(),
|
||||
provider,
|
||||
action: 'download',
|
||||
success: true,
|
||||
localVersion: remoteFile.meta.version,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
deviceName: remoteFile.meta.deviceName,
|
||||
});
|
||||
}
|
||||
this.notifyStateChange();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
1157
infrastructure/services/cloudSync/convergentSyncRuntimeMethods.ts
Normal file
1157
infrastructure/services/cloudSync/convergentSyncRuntimeMethods.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createConvergentSyncStateFromPayload } from '../../../domain/convergentSync/index.ts';
|
||||
import { SYNC_STORAGE_KEYS, type MasterKeyConfig, type SyncPayload } from '../../../domain/sync.ts';
|
||||
import {
|
||||
loadConvergentReplicaImpl,
|
||||
reencryptSyncStorageImpl,
|
||||
saveConvergentReplicaImpl,
|
||||
} from './convergentSyncStorageMethods.ts';
|
||||
import {
|
||||
decryptLocalStorageValue,
|
||||
encryptLocalStorageValue,
|
||||
} from './encryptedLocalStorage.ts';
|
||||
import { EncryptionService } from '../EncryptionService.ts';
|
||||
import { changeMasterKeyImpl } from './stateAndSecurityMethods.ts';
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
function payload(): SyncPayload {
|
||||
return {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: NOW,
|
||||
};
|
||||
}
|
||||
|
||||
async function key(): Promise<CryptoKey> {
|
||||
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
function manager(storage: Map<string, unknown>, derivedKey: CryptoKey) {
|
||||
return {
|
||||
state: { unlockedKey: { derivedKey } },
|
||||
loadFromStorage(storageKey: string) {
|
||||
return storage.get(storageKey) ?? null;
|
||||
},
|
||||
saveToStorage(storageKey: string, value: unknown) {
|
||||
storage.set(storageKey, value);
|
||||
},
|
||||
removeFromStorage(storageKey: string) {
|
||||
storage.delete(storageKey);
|
||||
},
|
||||
syncBaseKey(provider?: string) {
|
||||
return `${SYNC_STORAGE_KEYS.SYNC_BASE_PAYLOAD}${provider ? `_${provider}` : ''}`;
|
||||
},
|
||||
syncSnapshotsKey(provider?: string) {
|
||||
return `netcatty_sync_snapshots_v1${provider ? `_${provider}` : ''}`;
|
||||
},
|
||||
convergentProviderBaselineKey(provider: string) {
|
||||
return `${SYNC_STORAGE_KEYS.CONVERGENT_PROVIDER_BASELINE}_${provider}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('canonical replica records are encrypted and fail closed when corrupted', async () => {
|
||||
const storage = new Map<string, unknown>();
|
||||
const encryptionKey = await key();
|
||||
const subject = manager(storage, encryptionKey);
|
||||
const state = createConvergentSyncStateFromPayload(payload(), 'device-a', NOW);
|
||||
|
||||
await saveConvergentReplicaImpl.call(subject, { schemaVersion: 2, state, updatedAt: NOW });
|
||||
assert.equal(typeof storage.get(SYNC_STORAGE_KEYS.CONVERGENT_REPLICA), 'string');
|
||||
assert.equal((await loadConvergentReplicaImpl.call(subject))?.updatedAt, NOW);
|
||||
|
||||
storage.set(SYNC_STORAGE_KEYS.CONVERGENT_REPLICA, 'not-valid-ciphertext');
|
||||
await assert.rejects(() => loadConvergentReplicaImpl.call(subject));
|
||||
});
|
||||
|
||||
test('master key rotation re-encrypts all sync records before committing config', async () => {
|
||||
const storage = new Map<string, unknown>();
|
||||
const oldKey = await key();
|
||||
const newKey = await key();
|
||||
const subject = manager(storage, oldKey);
|
||||
const baseKey = subject.syncBaseKey('github');
|
||||
storage.set(baseKey, await encryptLocalStorageValue({ secret: 'value' }, oldKey));
|
||||
const oldConfig: MasterKeyConfig = {
|
||||
verificationHash: 'old',
|
||||
salt: 'old-salt',
|
||||
kdf: 'PBKDF2',
|
||||
createdAt: NOW,
|
||||
};
|
||||
const newConfig: MasterKeyConfig = { ...oldConfig, verificationHash: 'new', salt: 'new-salt' };
|
||||
storage.set(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, oldConfig);
|
||||
|
||||
await reencryptSyncStorageImpl.call(subject, oldKey, newKey, newConfig);
|
||||
const encoded = storage.get(baseKey) as string;
|
||||
assert.deepEqual(await decryptLocalStorageValue(encoded, newKey), { secret: 'value' });
|
||||
await assert.rejects(() => decryptLocalStorageValue(encoded, oldKey));
|
||||
assert.deepEqual(storage.get(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG), newConfig);
|
||||
});
|
||||
|
||||
test('master key rotation rolls ciphertext back when the config commit fails', async () => {
|
||||
const storage = new Map<string, unknown>();
|
||||
const oldKey = await key();
|
||||
const newKey = await key();
|
||||
const subject = manager(storage, oldKey);
|
||||
const baseKey = subject.syncBaseKey('github');
|
||||
const original = await encryptLocalStorageValue({ secret: 'value' }, oldKey);
|
||||
storage.set(baseKey, original);
|
||||
const oldConfig: MasterKeyConfig = {
|
||||
verificationHash: 'old',
|
||||
salt: 'old-salt',
|
||||
kdf: 'PBKDF2',
|
||||
createdAt: NOW,
|
||||
};
|
||||
storage.set(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, oldConfig);
|
||||
let failConfigWrite = true;
|
||||
subject.saveToStorage = (storageKey: string, value: unknown) => {
|
||||
if (storageKey === SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG && failConfigWrite) {
|
||||
failConfigWrite = false;
|
||||
throw new Error('quota');
|
||||
}
|
||||
storage.set(storageKey, value);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => reencryptSyncStorageImpl.call(subject, oldKey, newKey, { ...oldConfig, salt: 'new' }),
|
||||
/quota/,
|
||||
);
|
||||
assert.equal(storage.get(baseKey), original);
|
||||
assert.deepEqual(storage.get(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG), oldConfig);
|
||||
assert.deepEqual(await decryptLocalStorageValue(original, oldKey), { secret: 'value' });
|
||||
});
|
||||
|
||||
test('master key rotation aborts when an initially absent sync record appears', async () => {
|
||||
const storage = new Map<string, unknown>();
|
||||
const oldKey = await key();
|
||||
const newKey = await key();
|
||||
const subject = manager(storage, oldKey);
|
||||
const oldConfig: MasterKeyConfig = {
|
||||
verificationHash: 'old',
|
||||
salt: 'old-salt',
|
||||
kdf: 'PBKDF2',
|
||||
createdAt: NOW,
|
||||
};
|
||||
const newConfig: MasterKeyConfig = { ...oldConfig, verificationHash: 'new' };
|
||||
const appearedKey = subject.convergentProviderBaselineKey('github');
|
||||
const appearedCiphertext = await encryptLocalStorageValue({ created: 'concurrently' }, oldKey);
|
||||
storage.set(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, oldConfig);
|
||||
const loadFromStorage = subject.loadFromStorage.bind(subject);
|
||||
let appearedReads = 0;
|
||||
subject.loadFromStorage = (storageKey: string) => {
|
||||
if (storageKey === appearedKey) {
|
||||
appearedReads += 1;
|
||||
if (appearedReads === 1) return null;
|
||||
storage.set(appearedKey, appearedCiphertext);
|
||||
return appearedCiphertext;
|
||||
}
|
||||
return loadFromStorage(storageKey);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => reencryptSyncStorageImpl.call(subject, oldKey, newKey, newConfig),
|
||||
/Sync data changed while the master key was being rotated/,
|
||||
);
|
||||
|
||||
assert.deepEqual(storage.get(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG), oldConfig);
|
||||
assert.equal(storage.get(appearedKey), appearedCiphertext);
|
||||
assert.deepEqual(await decryptLocalStorageValue(appearedCiphertext, oldKey), {
|
||||
created: 'concurrently',
|
||||
});
|
||||
});
|
||||
|
||||
test('master key state changes only after derived-key records are re-encrypted', async () => {
|
||||
const oldChange = EncryptionService.changeMasterPassword;
|
||||
const oldUnlock = EncryptionService.unlockMasterKey;
|
||||
const oldConfig: MasterKeyConfig = {
|
||||
verificationHash: 'old',
|
||||
salt: 'old-salt',
|
||||
kdf: 'PBKDF2',
|
||||
createdAt: NOW,
|
||||
};
|
||||
const newConfig: MasterKeyConfig = { ...oldConfig, verificationHash: 'new', salt: 'new-salt' };
|
||||
const oldKey = await key();
|
||||
const newKey = await key();
|
||||
const calls: string[] = [];
|
||||
EncryptionService.changeMasterPassword = async () => newConfig;
|
||||
EncryptionService.unlockMasterKey = async (password) => ({
|
||||
derivedKey: password === 'old-password' ? oldKey : newKey,
|
||||
salt: new Uint8Array(),
|
||||
unlockedAt: NOW,
|
||||
});
|
||||
try {
|
||||
const subject = {
|
||||
state: {
|
||||
masterKeyConfig: oldConfig,
|
||||
securityState: 'UNLOCKED',
|
||||
unlockedKey: { derivedKey: oldKey },
|
||||
autoSyncEnabled: false,
|
||||
},
|
||||
masterPassword: 'old-password',
|
||||
reencryptSyncStorage: async () => {
|
||||
calls.push('reencrypt');
|
||||
assert.equal(subject.state.masterKeyConfig, oldConfig);
|
||||
assert.equal(subject.masterPassword, 'old-password');
|
||||
},
|
||||
bumpSyncSecurityGeneration: () => calls.push('generation'),
|
||||
emit: () => calls.push('emit'),
|
||||
};
|
||||
|
||||
const changed = await changeMasterKeyImpl.call(subject, 'old-password', 'new-password');
|
||||
|
||||
assert.equal(changed, true);
|
||||
assert.deepEqual(calls, ['reencrypt', 'generation', 'emit']);
|
||||
assert.equal(subject.state.masterKeyConfig, newConfig);
|
||||
assert.equal(subject.state.unlockedKey.derivedKey, newKey);
|
||||
assert.equal(subject.masterPassword, 'new-password');
|
||||
} finally {
|
||||
EncryptionService.changeMasterPassword = oldChange;
|
||||
EncryptionService.unlockMasterKey = oldUnlock;
|
||||
}
|
||||
});
|
||||
|
||||
test('failed local re-encryption leaves the active master key unchanged', async () => {
|
||||
const oldChange = EncryptionService.changeMasterPassword;
|
||||
const oldUnlock = EncryptionService.unlockMasterKey;
|
||||
const oldConfig: MasterKeyConfig = {
|
||||
verificationHash: 'old',
|
||||
salt: 'old-salt',
|
||||
kdf: 'PBKDF2',
|
||||
createdAt: NOW,
|
||||
};
|
||||
const oldKey = await key();
|
||||
EncryptionService.changeMasterPassword = async () => ({ ...oldConfig, verificationHash: 'new' });
|
||||
EncryptionService.unlockMasterKey = async () => ({
|
||||
derivedKey: oldKey,
|
||||
salt: new Uint8Array(),
|
||||
unlockedAt: NOW,
|
||||
});
|
||||
try {
|
||||
const subject = {
|
||||
state: {
|
||||
masterKeyConfig: oldConfig,
|
||||
securityState: 'UNLOCKED',
|
||||
unlockedKey: { derivedKey: oldKey },
|
||||
autoSyncEnabled: false,
|
||||
},
|
||||
masterPassword: 'old-password',
|
||||
reencryptSyncStorage: async () => {
|
||||
throw new Error('storage failed');
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => changeMasterKeyImpl.call(subject, 'old-password', 'new-password'),
|
||||
/storage failed/,
|
||||
);
|
||||
assert.equal(subject.state.masterKeyConfig, oldConfig);
|
||||
assert.equal(subject.masterPassword, 'old-password');
|
||||
assert.equal(subject.state.unlockedKey.derivedKey, oldKey);
|
||||
} finally {
|
||||
EncryptionService.changeMasterPassword = oldChange;
|
||||
EncryptionService.unlockMasterKey = oldUnlock;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import {
|
||||
SYNC_STORAGE_KEYS,
|
||||
type CloudProvider,
|
||||
type ConvergentProviderBaselineV2,
|
||||
type ConvergentReplicaRecordV2,
|
||||
type MasterKeyConfig,
|
||||
} from '../../../domain/sync';
|
||||
import {
|
||||
assertValidConvergentSyncState,
|
||||
canonicalizeConvergentSyncState,
|
||||
stripConvergentSyncEnvelope,
|
||||
} from '../../../domain/convergentSync';
|
||||
import {
|
||||
decryptLocalStorageValue,
|
||||
encryptLocalStorageValue,
|
||||
} from './encryptedLocalStorage';
|
||||
|
||||
/** Built-ins plus any dynamic plugin providers currently in manager state. */
|
||||
function listedProviders(manager: any): CloudProvider[] {
|
||||
const fromState = Object.keys(manager?.state?.providers ?? {}) as CloudProvider[];
|
||||
if (fromState.length > 0) return fromState.sort();
|
||||
return ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
}
|
||||
|
||||
export function convergentProviderBaselineKeyImpl(this: any, provider: CloudProvider): string {
|
||||
return `${SYNC_STORAGE_KEYS.CONVERGENT_PROVIDER_BASELINE}_${provider}`;
|
||||
}
|
||||
|
||||
function requireLocalEncryptionKey(manager: any): CryptoKey {
|
||||
const key = manager.state.unlockedKey?.derivedKey;
|
||||
if (!key) throw new Error('Convergent sync encryption key is unavailable');
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function saveConvergentReplicaImpl(
|
||||
this: any,
|
||||
record: ConvergentReplicaRecordV2,
|
||||
): Promise<void> {
|
||||
if (record.schemaVersion !== 2) throw new Error('Unsupported convergent replica schema');
|
||||
assertValidConvergentSyncState(record.state);
|
||||
const normalized: ConvergentReplicaRecordV2 = {
|
||||
schemaVersion: 2,
|
||||
state: canonicalizeConvergentSyncState(record.state),
|
||||
updatedAt: record.updatedAt,
|
||||
};
|
||||
if (this.saveToStorage(
|
||||
SYNC_STORAGE_KEYS.CONVERGENT_REPLICA,
|
||||
await encryptLocalStorageValue(normalized, requireLocalEncryptionKey(this)),
|
||||
) === false) throw new Error('Unable to persist convergent sync replica');
|
||||
}
|
||||
|
||||
export async function loadConvergentReplicaImpl(this: any): Promise<ConvergentReplicaRecordV2 | null> {
|
||||
const encoded = this.loadFromStorage(SYNC_STORAGE_KEYS.CONVERGENT_REPLICA) as unknown;
|
||||
if (!encoded) return null;
|
||||
if (typeof encoded !== 'string') throw new Error('Convergent replica record is invalid');
|
||||
const record = await decryptLocalStorageValue<ConvergentReplicaRecordV2>(
|
||||
encoded,
|
||||
requireLocalEncryptionKey(this),
|
||||
);
|
||||
if (record?.schemaVersion !== 2 || !Number.isFinite(record.updatedAt)) {
|
||||
throw new Error('Convergent replica record has an unsupported schema');
|
||||
}
|
||||
assertValidConvergentSyncState(record.state);
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
state: canonicalizeConvergentSyncState(record.state),
|
||||
updatedAt: record.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveConvergentProviderBaselineImpl(
|
||||
this: any,
|
||||
baseline: ConvergentProviderBaselineV2,
|
||||
): Promise<void> {
|
||||
if (baseline.schemaVersion !== 2) throw new Error('Unsupported convergent baseline schema');
|
||||
assertValidConvergentSyncState(baseline.state);
|
||||
const normalized: ConvergentProviderBaselineV2 = {
|
||||
...baseline,
|
||||
materializedPayload: stripConvergentSyncEnvelope(baseline.materializedPayload),
|
||||
state: canonicalizeConvergentSyncState(baseline.state),
|
||||
};
|
||||
if (this.saveToStorage(
|
||||
this.convergentProviderBaselineKey(baseline.provider),
|
||||
await encryptLocalStorageValue(normalized, requireLocalEncryptionKey(this)),
|
||||
) === false) throw new Error(`Unable to persist convergent baseline for ${baseline.provider}`);
|
||||
}
|
||||
|
||||
export async function loadConvergentProviderBaselineImpl(
|
||||
this: any,
|
||||
provider: CloudProvider,
|
||||
): Promise<ConvergentProviderBaselineV2 | null> {
|
||||
const encoded = this.loadFromStorage(this.convergentProviderBaselineKey(provider)) as unknown;
|
||||
if (!encoded) return null;
|
||||
if (typeof encoded !== 'string') throw new Error(`Convergent baseline for ${provider} is invalid`);
|
||||
const baseline = await decryptLocalStorageValue<ConvergentProviderBaselineV2>(
|
||||
encoded,
|
||||
requireLocalEncryptionKey(this),
|
||||
);
|
||||
if (baseline?.schemaVersion !== 2 || baseline.provider !== provider) {
|
||||
throw new Error(`Convergent baseline for ${provider} has an unsupported schema`);
|
||||
}
|
||||
assertValidConvergentSyncState(baseline.state);
|
||||
return {
|
||||
...baseline,
|
||||
materializedPayload: stripConvergentSyncEnvelope(baseline.materializedPayload),
|
||||
state: canonicalizeConvergentSyncState(baseline.state),
|
||||
};
|
||||
}
|
||||
|
||||
export function clearConvergentSyncStorageImpl(this: any, confirmed: boolean): void {
|
||||
if (!confirmed) throw new Error('Explicit confirmation is required to remove convergent sync state');
|
||||
this.removeFromStorage(SYNC_STORAGE_KEYS.CONVERGENT_REPLICA);
|
||||
for (const provider of listedProviders(this)) {
|
||||
this.removeFromStorage(this.convergentProviderBaselineKey(provider));
|
||||
}
|
||||
}
|
||||
|
||||
function encryptedSyncStorageKeys(manager: any): string[] {
|
||||
const keys = new Set<string>([
|
||||
manager.syncBaseKey(),
|
||||
manager.syncSnapshotsKey(),
|
||||
SYNC_STORAGE_KEYS.CONVERGENT_REPLICA,
|
||||
]);
|
||||
for (const provider of listedProviders(manager)) {
|
||||
keys.add(manager.syncBaseKey(provider));
|
||||
keys.add(manager.syncSnapshotsKey(provider));
|
||||
keys.add(manager.convergentProviderBaselineKey(provider));
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-encrypt every derived-key local record before committing the new master
|
||||
* configuration. Values are prepared first, concurrent changes abort the
|
||||
* transaction, and any write failure rolls all keys back to their exact prior
|
||||
* ciphertext.
|
||||
*/
|
||||
export async function reencryptSyncStorageImpl(
|
||||
this: any,
|
||||
oldKey: CryptoKey,
|
||||
newKey: CryptoKey,
|
||||
newConfig: MasterKeyConfig,
|
||||
): Promise<void> {
|
||||
const keys = encryptedSyncStorageKeys(this);
|
||||
const previousConfig = (
|
||||
this.loadFromStorage(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG)
|
||||
?? this.state.masterKeyConfig
|
||||
) as MasterKeyConfig | null;
|
||||
const originals = new Map<string, string | null>();
|
||||
const replacements = new Map<string, string>();
|
||||
for (const key of keys) {
|
||||
const encoded = this.loadFromStorage(key) as unknown;
|
||||
if (encoded == null) {
|
||||
originals.set(key, null);
|
||||
continue;
|
||||
}
|
||||
if (typeof encoded !== 'string') throw new Error(`Encrypted sync record ${key} is invalid`);
|
||||
originals.set(key, encoded);
|
||||
const value = await decryptLocalStorageValue<unknown>(encoded, oldKey);
|
||||
replacements.set(key, await encryptLocalStorageValue(value, newKey));
|
||||
}
|
||||
for (const [key, original] of originals) {
|
||||
const current = this.loadFromStorage(key) as unknown;
|
||||
if ((current ?? null) !== original) {
|
||||
throw new Error('Sync data changed while the master key was being rotated');
|
||||
}
|
||||
}
|
||||
const currentConfig = this.loadFromStorage(
|
||||
SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG,
|
||||
) as MasterKeyConfig | null;
|
||||
if (JSON.stringify(currentConfig) !== JSON.stringify(previousConfig)) {
|
||||
throw new Error('Master key configuration changed while it was being rotated');
|
||||
}
|
||||
const committed: string[] = [];
|
||||
try {
|
||||
for (const [key, replacement] of replacements) {
|
||||
if (this.saveToStorage(key, replacement) === false) {
|
||||
throw new Error(`Unable to persist re-encrypted sync record: ${key}`);
|
||||
}
|
||||
committed.push(key);
|
||||
}
|
||||
if (this.saveToStorage(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, newConfig) === false) {
|
||||
throw new Error('Unable to persist the new master key configuration');
|
||||
}
|
||||
} catch (error) {
|
||||
for (const key of committed.reverse()) {
|
||||
const original = originals.get(key);
|
||||
if (original !== undefined) this.saveToStorage(key, original);
|
||||
}
|
||||
if (previousConfig) this.saveToStorage(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, previousConfig);
|
||||
else this.removeFromStorage(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
26
infrastructure/services/cloudSync/encryptedLocalStorage.ts
Normal file
26
infrastructure/services/cloudSync/encryptedLocalStorage.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export async function encryptLocalStorageValue(value: unknown, key: CryptoKey): Promise<string> {
|
||||
const data = new TextEncoder().encode(JSON.stringify(value));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);
|
||||
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
||||
combined.set(iv);
|
||||
combined.set(new Uint8Array(encrypted), iv.length);
|
||||
let binary = '';
|
||||
const chunkSize = 8192;
|
||||
for (let offset = 0; offset < combined.length; offset += chunkSize) {
|
||||
binary += String.fromCharCode(...combined.subarray(offset, offset + chunkSize));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export async function decryptLocalStorageValue<T>(
|
||||
encoded: string,
|
||||
key: CryptoKey,
|
||||
): Promise<T> {
|
||||
const combined = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0));
|
||||
if (combined.length <= 12) throw new Error('Encrypted local sync record is truncated');
|
||||
const iv = combined.slice(0, 12);
|
||||
const ciphertext = combined.slice(12);
|
||||
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
|
||||
return JSON.parse(new TextDecoder().decode(decrypted)) as T;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { SYNC_STORAGE_KEYS } from '../../../domain/sync.ts';
|
||||
import { loadInitialStateImpl, loadProviderConnectionImpl } from './stateAndSecurityMethods.ts';
|
||||
|
||||
test('startup does not eagerly write SYNC_PREFERENCES over a concurrent autoSync=false', () => {
|
||||
const storage = new Map<string, unknown>();
|
||||
storage.set(SYNC_STORAGE_KEYS.DEVICE_ID, 'test-device');
|
||||
storage.set(SYNC_STORAGE_KEYS.DEVICE_NAME, 'Test Device');
|
||||
// Legacy combined blob from pre-split builds.
|
||||
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
|
||||
autoSync: true,
|
||||
interval: 5,
|
||||
syncStrategy: 'smartMerge',
|
||||
localVersion: 2,
|
||||
localUpdatedAt: 100,
|
||||
remoteVersion: 2,
|
||||
remoteUpdatedAt: 100,
|
||||
});
|
||||
|
||||
let preferenceReads = 0;
|
||||
const manager = {
|
||||
providerWriteSeq: {} as Record<string, number>,
|
||||
providerDecryptSeq: {} as Record<string, number>,
|
||||
providerDecrypted: {} as Record<string, boolean>,
|
||||
providerAuthAttemptSeq: {} as Record<string, number>,
|
||||
providerAuthRestoreState: {} as Record<string, unknown>,
|
||||
loadFromStorage(key: string) {
|
||||
if (key === SYNC_STORAGE_KEYS.SYNC_PREFERENCES) {
|
||||
preferenceReads += 1;
|
||||
// Concurrent window already wrote autoSync=false before this
|
||||
// process's preference read.
|
||||
return {
|
||||
autoSync: false,
|
||||
interval: 5,
|
||||
syncStrategy: 'smartMerge',
|
||||
};
|
||||
}
|
||||
return storage.get(key) ?? null;
|
||||
},
|
||||
saveToStorage(key: string, value: unknown) {
|
||||
storage.set(key, value);
|
||||
return true;
|
||||
},
|
||||
loadProviderConnection(provider: string) {
|
||||
return loadProviderConnectionImpl.call(this, provider);
|
||||
},
|
||||
};
|
||||
|
||||
const state = loadInitialStateImpl.call(manager);
|
||||
|
||||
assert.equal(
|
||||
storage.has(SYNC_STORAGE_KEYS.SYNC_PREFERENCES),
|
||||
false,
|
||||
'must not eagerly write SYNC_PREFERENCES on startup',
|
||||
);
|
||||
assert.equal(preferenceReads, 1);
|
||||
assert.equal(state.autoSyncEnabled, false);
|
||||
});
|
||||
|
||||
test('startup adopts legacy SYNC_CONFIG prefs without writing SYNC_PREFERENCES', () => {
|
||||
const storage = new Map<string, unknown>();
|
||||
storage.set(SYNC_STORAGE_KEYS.DEVICE_ID, 'test-device');
|
||||
storage.set(SYNC_STORAGE_KEYS.DEVICE_NAME, 'Test Device');
|
||||
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
|
||||
autoSync: true,
|
||||
interval: 15,
|
||||
syncStrategy: 'preferCloud',
|
||||
localVersion: 1,
|
||||
localUpdatedAt: 1,
|
||||
remoteVersion: 1,
|
||||
remoteUpdatedAt: 1,
|
||||
});
|
||||
|
||||
const manager = {
|
||||
providerWriteSeq: {} as Record<string, number>,
|
||||
providerDecryptSeq: {} as Record<string, number>,
|
||||
providerDecrypted: {} as Record<string, boolean>,
|
||||
providerAuthAttemptSeq: {} as Record<string, number>,
|
||||
providerAuthRestoreState: {} as Record<string, unknown>,
|
||||
loadFromStorage(key: string) {
|
||||
return storage.get(key) ?? null;
|
||||
},
|
||||
saveToStorage(key: string, value: unknown) {
|
||||
storage.set(key, value);
|
||||
return true;
|
||||
},
|
||||
loadProviderConnection(provider: string) {
|
||||
return loadProviderConnectionImpl.call(this, provider);
|
||||
},
|
||||
};
|
||||
|
||||
const state = loadInitialStateImpl.call(manager);
|
||||
|
||||
assert.equal(
|
||||
storage.has(SYNC_STORAGE_KEYS.SYNC_PREFERENCES),
|
||||
false,
|
||||
'startup must not eagerly migrate preferences to disk',
|
||||
);
|
||||
assert.equal(state.autoSyncEnabled, true);
|
||||
assert.equal(state.autoSyncInterval, 15);
|
||||
assert.equal(state.syncStrategy, 'preferCloud');
|
||||
});
|
||||
263
infrastructure/services/cloudSync/persistRefreshedTokens.test.ts
Normal file
263
infrastructure/services/cloudSync/persistRefreshedTokens.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
attachTokenRefreshPersistence,
|
||||
handleProviderReauthRequiredImpl,
|
||||
persistRefreshedProviderTokensImpl,
|
||||
} from './stateAndSecurityMethods.ts';
|
||||
import { inspectProviderRemoteStateImpl } from './authMethods.ts';
|
||||
import {
|
||||
ONEDRIVE_REAUTH_REQUIRED_MARKER,
|
||||
isProviderReadyForSync,
|
||||
type OAuthTokens,
|
||||
type ProviderConnection,
|
||||
} from '../../../domain/sync.ts';
|
||||
|
||||
const newTokens = (): OAuthTokens => ({
|
||||
accessToken: 'fresh-access',
|
||||
refreshToken: 'rotated-refresh',
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
tokenType: 'Bearer',
|
||||
});
|
||||
|
||||
function createManager() {
|
||||
const saved: Array<{ provider: string; tokens?: OAuthTokens }> = [];
|
||||
let notified = 0;
|
||||
const manager = {
|
||||
providerDecryptSeq: { onedrive: 0, google: 0 } as Record<string, number>,
|
||||
adapters: new Map<string, { signOut: () => void }>(),
|
||||
state: {
|
||||
providers: {
|
||||
onedrive: {
|
||||
provider: 'onedrive',
|
||||
status: 'connected',
|
||||
tokens: { accessToken: 'old', refreshToken: 'old-refresh', tokenType: 'Bearer' },
|
||||
account: { id: 'u1' },
|
||||
resourceId: 'file-1',
|
||||
} as ProviderConnection,
|
||||
} as Record<string, ProviderConnection>,
|
||||
},
|
||||
saveProviderConnection: async (provider: string, connection: { tokens?: OAuthTokens }) => {
|
||||
saved.push({ provider, tokens: connection.tokens });
|
||||
},
|
||||
notifyStateChange: () => {
|
||||
notified += 1;
|
||||
},
|
||||
};
|
||||
return { manager, saved, getNotified: () => notified };
|
||||
}
|
||||
|
||||
test('persistRefreshedProviderTokens updates state, persists, and notifies', async () => {
|
||||
const { manager, saved, getNotified } = createManager();
|
||||
const tokens = newTokens();
|
||||
|
||||
persistRefreshedProviderTokensImpl.call(manager, 'onedrive', tokens);
|
||||
// saveProviderConnection is fire-and-forget; let microtasks flush.
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(manager.state.providers.onedrive.tokens, tokens);
|
||||
// Other fields are preserved.
|
||||
assert.equal(manager.state.providers.onedrive.account?.id, 'u1');
|
||||
assert.equal(manager.state.providers.onedrive.resourceId, 'file-1');
|
||||
assert.equal(manager.state.providers.onedrive.status, 'connected');
|
||||
|
||||
assert.equal(saved.length, 1);
|
||||
assert.equal(saved[0].provider, 'onedrive');
|
||||
assert.deepEqual(saved[0].tokens, tokens);
|
||||
|
||||
// Decrypt sequence is bumped so an in-flight decrypt cannot clobber the write.
|
||||
assert.equal(manager.providerDecryptSeq.onedrive, 1);
|
||||
assert.equal(getNotified(), 1);
|
||||
});
|
||||
|
||||
test('persistRefreshedProviderTokens is a no-op when the provider was disconnected', async () => {
|
||||
const { manager, saved } = createManager();
|
||||
// Simulate a disconnect happening during the async refresh.
|
||||
manager.state.providers.onedrive = { provider: 'onedrive', status: 'disconnected' };
|
||||
|
||||
persistRefreshedProviderTokensImpl.call(manager, 'onedrive', newTokens());
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(saved.length, 0);
|
||||
assert.equal(manager.state.providers.onedrive.tokens, undefined);
|
||||
});
|
||||
|
||||
test('attachTokenRefreshPersistence wires adapters that expose setOnTokensRefreshed', () => {
|
||||
const { manager, saved } = createManager();
|
||||
let registered: ((tokens: OAuthTokens) => void) | null = null;
|
||||
const adapter = {
|
||||
setOnTokensRefreshed(cb: (tokens: OAuthTokens) => void) {
|
||||
registered = cb;
|
||||
},
|
||||
};
|
||||
|
||||
attachTokenRefreshPersistence.call(manager, 'onedrive', adapter as never);
|
||||
assert.equal(typeof registered, 'function');
|
||||
|
||||
// Invoking the registered callback persists, proving the wiring is correct.
|
||||
const tokens = newTokens();
|
||||
registered!(tokens);
|
||||
assert.equal(saved.length, 1);
|
||||
assert.deepEqual(saved[0].tokens, tokens);
|
||||
});
|
||||
|
||||
test('attachTokenRefreshPersistence is a no-op for adapters without the hook', () => {
|
||||
const { manager } = createManager();
|
||||
// Adapter without setOnTokensRefreshed (e.g. GitHub) must not throw.
|
||||
assert.doesNotThrow(() =>
|
||||
attachTokenRefreshPersistence.call(manager, 'github', {} as never),
|
||||
);
|
||||
});
|
||||
|
||||
test('attachTokenRefreshPersistence persists Google tokens refreshed mid-session', async () => {
|
||||
// End-to-end: the real GoogleDriveAdapter refreshes during an operation and
|
||||
// the rotated tokens reach saveProviderConnection (the regression #1208 fixed
|
||||
// for OneDrive — Google previously had no setOnTokensRefreshed hook).
|
||||
const { manager, saved } = createManager();
|
||||
manager.providerDecryptSeq.google = 0;
|
||||
manager.state.providers.google = {
|
||||
provider: 'google',
|
||||
status: 'connected',
|
||||
tokens: { accessToken: 'old', refreshToken: 'google-refresh', tokenType: 'Bearer' },
|
||||
account: { id: 'g1' },
|
||||
resourceId: 'gfile-1',
|
||||
} as ProviderConnection;
|
||||
|
||||
const g = globalThis as typeof globalThis & { window?: unknown };
|
||||
const originalWindow = g.window;
|
||||
g.window = {
|
||||
netcatty: {
|
||||
// Google's refresh response omits refresh_token — the adapter must carry
|
||||
// the previous one forward so the persisted connection stays refreshable.
|
||||
googleRefreshAccessToken: async () => ({
|
||||
accessToken: 'fresh-access',
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
tokenType: 'Bearer',
|
||||
}),
|
||||
googleDriveDownloadSyncFile: async () => ({
|
||||
syncedFile: { meta: { version: 1 }, payload: 'x' },
|
||||
}),
|
||||
},
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
try {
|
||||
const { GoogleDriveAdapter } = await import('../adapters/GoogleDriveAdapter.ts');
|
||||
const adapter = new GoogleDriveAdapter(
|
||||
{
|
||||
accessToken: 'old',
|
||||
refreshToken: 'google-refresh',
|
||||
// Expired so the operation forces a refresh.
|
||||
expiresAt: Date.now() - 60_000,
|
||||
tokenType: 'Bearer',
|
||||
},
|
||||
'gfile-1',
|
||||
);
|
||||
|
||||
attachTokenRefreshPersistence.call(manager, 'google', adapter as never);
|
||||
|
||||
await adapter.download();
|
||||
// persistRefreshedProviderTokens fires saveProviderConnection fire-and-forget.
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(saved.length, 1);
|
||||
assert.equal(saved[0].provider, 'google');
|
||||
assert.equal(saved[0].tokens?.accessToken, 'fresh-access');
|
||||
// Original refresh token preserved despite the omitted refresh_token.
|
||||
assert.equal(saved[0].tokens?.refreshToken, 'google-refresh');
|
||||
// State updated and other fields preserved.
|
||||
assert.equal(manager.state.providers.google.tokens?.accessToken, 'fresh-access');
|
||||
assert.equal(manager.state.providers.google.account?.id, 'g1');
|
||||
assert.equal(manager.state.providers.google.resourceId, 'gfile-1');
|
||||
assert.equal(manager.providerDecryptSeq.google, 1);
|
||||
} finally {
|
||||
g.window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
test('handleProviderReauthRequired clears OneDrive tokens and stops it being sync-ready', async () => {
|
||||
const { manager, saved } = createManager();
|
||||
let signedOut = false;
|
||||
manager.adapters.set('onedrive', { signOut: () => { signedOut = true; } });
|
||||
|
||||
const handled = handleProviderReauthRequiredImpl.call(
|
||||
manager,
|
||||
'onedrive',
|
||||
new Error(
|
||||
`Download failed: ${ONEDRIVE_REAUTH_REQUIRED_MARKER}: OneDrive session expired, please reconnect. (AADSTS70000)`,
|
||||
),
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.equal(signedOut, true);
|
||||
// Stale adapter is evicted.
|
||||
assert.equal(manager.adapters.has('onedrive'), false);
|
||||
|
||||
const conn = manager.state.providers.onedrive;
|
||||
assert.equal(conn.tokens, undefined);
|
||||
assert.equal(conn.status, 'error');
|
||||
// Account is preserved for display; error message is cleaned of the marker.
|
||||
assert.equal(conn.account?.id, 'u1');
|
||||
assert.ok(conn.error && !conn.error.includes(ONEDRIVE_REAUTH_REQUIRED_MARKER));
|
||||
assert.match(conn.error ?? '', /please reconnect/);
|
||||
|
||||
// Crucial: cleared tokens => not ready for sync => auto-sync won't retry.
|
||||
assert.equal(isProviderReadyForSync(conn), false);
|
||||
|
||||
// Persisted the cleared connection.
|
||||
assert.equal(saved.length, 1);
|
||||
assert.equal(saved[0].tokens, undefined);
|
||||
});
|
||||
|
||||
test('handleProviderReauthRequired ignores non-OneDrive providers and unrelated errors', () => {
|
||||
const { manager } = createManager();
|
||||
|
||||
// Wrong provider.
|
||||
assert.equal(
|
||||
handleProviderReauthRequiredImpl.call(
|
||||
manager,
|
||||
'google',
|
||||
new Error(`${ONEDRIVE_REAUTH_REQUIRED_MARKER}: x`),
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
// OneDrive but an ordinary (retryable) error — must not clear tokens.
|
||||
assert.equal(
|
||||
handleProviderReauthRequiredImpl.call(manager, 'onedrive', new Error('network timeout')),
|
||||
false,
|
||||
);
|
||||
assert.ok(manager.state.providers.onedrive.tokens);
|
||||
});
|
||||
|
||||
test('inspectProviderRemoteState clears OneDrive tokens on a reauth-required download error', async () => {
|
||||
const { manager, saved } = createManager();
|
||||
// Provide the manager surface inspectProviderRemoteState touches.
|
||||
Object.assign(manager, {
|
||||
handleProviderReauthRequired(provider: string, error: unknown) {
|
||||
return handleProviderReauthRequiredImpl.call(manager, provider as never, error);
|
||||
},
|
||||
createSyncedFileSignature: async () => null,
|
||||
loadSyncAnchor: () => null,
|
||||
});
|
||||
|
||||
const adapter = {
|
||||
resourceId: 'file-1',
|
||||
download: async () => {
|
||||
throw new Error(
|
||||
`Download failed: ${ONEDRIVE_REAUTH_REQUIRED_MARKER}: OneDrive session expired, please reconnect.`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const result = await inspectProviderRemoteStateImpl.call(manager, 'onedrive', adapter as never);
|
||||
await Promise.resolve();
|
||||
|
||||
// The inspection reports an error (so callers fail closed)...
|
||||
assert.ok(result.error);
|
||||
// ...and the dead credentials were cleared so the provider is no longer ready.
|
||||
assert.equal(manager.state.providers.onedrive.tokens, undefined);
|
||||
assert.equal(isProviderReadyForSync(manager.state.providers.onedrive), false);
|
||||
assert.equal(saved.length, 1);
|
||||
});
|
||||
442
infrastructure/services/cloudSync/pluginProviderBoundary.test.ts
Normal file
442
infrastructure/services/cloudSync/pluginProviderBoundary.test.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { CloudProvider, ProviderConnection, SyncedFile } from '../../../domain/sync';
|
||||
import { SYNC_STORAGE_KEYS } from '../../../domain/sync';
|
||||
import type { EncryptedObjectStorage } from '../../../domain/encryptedObjectStorage';
|
||||
import { DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY } from '../../../domain/encryptedObjectStorage';
|
||||
import {
|
||||
enforceLegacySingleProviderConnected,
|
||||
getConnectedAdapterImpl,
|
||||
listAvailablePluginSyncProviderIdsImpl,
|
||||
listRegisteredPluginProviderIdsImpl,
|
||||
loadInitialStateImpl,
|
||||
loadProviderConnectionImpl,
|
||||
registerPluginProviderIdImpl,
|
||||
saveProviderConnectionImpl,
|
||||
setAvailablePluginSyncProviderIdsImpl,
|
||||
unregisterPluginProviderIdImpl,
|
||||
} from './stateAndSecurityMethods';
|
||||
|
||||
function makeSyncedFile(version: number, payload = 'cipher'): SyncedFile {
|
||||
return {
|
||||
meta: {
|
||||
version,
|
||||
updatedAt: 1,
|
||||
deviceId: 'device',
|
||||
appVersion: '0.0.0',
|
||||
iv: 'iv',
|
||||
salt: 'salt',
|
||||
algorithm: 'AES-256-GCM',
|
||||
kdf: 'PBKDF2',
|
||||
},
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function memoryStorage(): Map<string, unknown> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
type ManagerHarness = {
|
||||
adapters: Map<string, unknown>;
|
||||
providerWriteSeq: Record<string, number>;
|
||||
providerDecryptSeq: Record<string, number>;
|
||||
providerDecrypted: Record<string, boolean>;
|
||||
providerAuthAttemptSeq: Record<string, number>;
|
||||
providerAuthRestoreState: Record<string, unknown>;
|
||||
decryptionReady: Promise<void>;
|
||||
state: { providers: Record<string, ProviderConnection> } | null;
|
||||
createPluginStorage?: (id: string) => Promise<EncryptedObjectStorage>;
|
||||
loadFromStorage: <T>(key: string) => T | null;
|
||||
saveToStorage: (key: string, value: unknown) => boolean;
|
||||
removeFromStorage: (key: string) => void;
|
||||
loadProviderConnection: (provider: CloudProvider) => ProviderConnection;
|
||||
notifyStateChange: () => void;
|
||||
isActiveAuthAttempt: () => boolean;
|
||||
};
|
||||
|
||||
function createManagerHarness(storage: Map<string, unknown>): ManagerHarness {
|
||||
const manager: ManagerHarness = {
|
||||
adapters: new Map(),
|
||||
providerWriteSeq: {
|
||||
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
|
||||
},
|
||||
providerDecryptSeq: {
|
||||
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
|
||||
},
|
||||
providerDecrypted: {
|
||||
github: true, google: true, onedrive: true, webdav: true, s3: true,
|
||||
},
|
||||
providerAuthAttemptSeq: {
|
||||
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
|
||||
},
|
||||
providerAuthRestoreState: {
|
||||
github: null, google: null, onedrive: null, webdav: null, s3: null,
|
||||
},
|
||||
decryptionReady: Promise.resolve(),
|
||||
state: null,
|
||||
loadFromStorage<T>(key: string): T | null {
|
||||
return (storage.has(key) ? storage.get(key) : null) as T | null;
|
||||
},
|
||||
saveToStorage(key: string, value: unknown) {
|
||||
storage.set(key, value);
|
||||
return true;
|
||||
},
|
||||
removeFromStorage(key: string) {
|
||||
storage.delete(key);
|
||||
},
|
||||
loadProviderConnection(provider: CloudProvider) {
|
||||
return loadProviderConnectionImpl.call(manager, provider);
|
||||
},
|
||||
notifyStateChange() {},
|
||||
isActiveAuthAttempt() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return manager;
|
||||
}
|
||||
|
||||
describe('plugin provider manager boundary', () => {
|
||||
it('loads registered plugin providers into initial state and keeps them across restart', () => {
|
||||
const storage = memoryStorage();
|
||||
// Seed device identity so loadInitialStateImpl never touches browser globals.
|
||||
storage.set(SYNC_STORAGE_KEYS.DEVICE_ID, 'test-device');
|
||||
storage.set(SYNC_STORAGE_KEYS.DEVICE_NAME, 'Test Device');
|
||||
const manager = createManagerHarness(storage);
|
||||
registerPluginProviderIdImpl.call(manager, 'com.example.backup.sync');
|
||||
storage.set('netcatty_provider_plugin_v1:com.example.backup.sync', {
|
||||
provider: 'com.example.backup.sync',
|
||||
status: 'connected',
|
||||
config: { endpoint: 'https://example.test' },
|
||||
});
|
||||
|
||||
const state = loadInitialStateImpl.call(manager);
|
||||
assert.ok(state.providers['com.example.backup.sync']);
|
||||
// Without the plugin host IPC surface, dynamic providers keep config but
|
||||
// must not join sync cycles as "connected".
|
||||
assert.equal(state.providers['com.example.backup.sync'].status, 'disconnected');
|
||||
assert.deepEqual(
|
||||
(state.providers['com.example.backup.sync'].config as { endpoint?: string })?.endpoint,
|
||||
'https://example.test',
|
||||
);
|
||||
assert.deepEqual(listRegisteredPluginProviderIdsImpl.call(manager), [
|
||||
'com.example.backup.sync',
|
||||
]);
|
||||
});
|
||||
|
||||
it('setAvailablePluginSyncProviderIds drops missing providers from the ready set', () => {
|
||||
const storage = memoryStorage();
|
||||
const manager = createManagerHarness(storage);
|
||||
manager.state = {
|
||||
providers: {
|
||||
'com.example.backup.sync': {
|
||||
provider: 'com.example.backup.sync',
|
||||
status: 'connected',
|
||||
config: { endpoint: 'https://example.test' },
|
||||
},
|
||||
},
|
||||
};
|
||||
manager.adapters = new Map();
|
||||
registerPluginProviderIdImpl.call(manager, 'com.example.backup.sync');
|
||||
assert.deepEqual(listAvailablePluginSyncProviderIdsImpl.call(manager), [
|
||||
'com.example.backup.sync',
|
||||
]);
|
||||
// Plugin uninstalled / contribution gone.
|
||||
setAvailablePluginSyncProviderIdsImpl.call(manager, []);
|
||||
assert.deepEqual(listAvailablePluginSyncProviderIdsImpl.call(manager), []);
|
||||
assert.equal(manager.state.providers['com.example.backup.sync'].status, 'disconnected');
|
||||
});
|
||||
|
||||
it('setAvailablePluginSyncProviderIds keeps adapters when availability membership is unchanged', () => {
|
||||
const storage = memoryStorage();
|
||||
const manager = createManagerHarness(storage);
|
||||
const pluginId = 'com.example.backup.sync';
|
||||
let signedOut = 0;
|
||||
manager.state = {
|
||||
providers: {
|
||||
[pluginId]: {
|
||||
provider: pluginId,
|
||||
status: 'connected',
|
||||
config: { endpoint: 'https://example.test' },
|
||||
},
|
||||
},
|
||||
};
|
||||
manager.adapters = new Map([
|
||||
[pluginId, {
|
||||
isAuthenticated: true,
|
||||
accountInfo: { id: 'acct' },
|
||||
resourceId: null,
|
||||
signOut() { signedOut += 1; },
|
||||
async initializeSync() { return null; },
|
||||
async upload() { return 'ok'; },
|
||||
async download() { return null; },
|
||||
async deleteSync() {},
|
||||
getTokens() { return null; },
|
||||
}],
|
||||
]);
|
||||
registerPluginProviderIdImpl.call(manager, pluginId);
|
||||
// Same contribution still present (setting-updated noise).
|
||||
setAvailablePluginSyncProviderIdsImpl.call(manager, [pluginId]);
|
||||
assert.equal(manager.adapters.has(pluginId), true, 'must keep adapter on no-op membership refresh');
|
||||
assert.equal(signedOut, 0);
|
||||
assert.equal(manager.state.providers[pluginId].status, 'connected');
|
||||
});
|
||||
|
||||
it('setAvailablePluginSyncProviderIds drops adapters when a provider re-enters the set', () => {
|
||||
const storage = memoryStorage();
|
||||
const manager = createManagerHarness(storage);
|
||||
const pluginId = 'com.example.backup.sync';
|
||||
let signedOut = 0;
|
||||
manager.state = {
|
||||
providers: {
|
||||
[pluginId]: {
|
||||
provider: pluginId,
|
||||
status: 'disconnected',
|
||||
config: { endpoint: 'https://example.test' },
|
||||
error: 'Plugin sync provider is no longer installed or enabled',
|
||||
},
|
||||
},
|
||||
};
|
||||
manager.adapters = new Map([
|
||||
[pluginId, {
|
||||
isAuthenticated: true,
|
||||
accountInfo: { id: 'acct' },
|
||||
resourceId: null,
|
||||
signOut() { signedOut += 1; },
|
||||
async initializeSync() { return null; },
|
||||
async upload() { return 'ok'; },
|
||||
async download() { return null; },
|
||||
async deleteSync() {},
|
||||
getTokens() { return null; },
|
||||
}],
|
||||
]);
|
||||
// Not in available set yet.
|
||||
setAvailablePluginSyncProviderIdsImpl.call(manager, []);
|
||||
assert.equal(manager.adapters.has(pluginId), false);
|
||||
// Stale adapter reattached (should not happen in prod) then re-enter.
|
||||
manager.adapters.set(pluginId, {
|
||||
isAuthenticated: true,
|
||||
accountInfo: { id: 'acct' },
|
||||
resourceId: null,
|
||||
signOut() { signedOut += 1; },
|
||||
async initializeSync() { return null; },
|
||||
async upload() { return 'ok'; },
|
||||
async download() { return null; },
|
||||
async deleteSync() {},
|
||||
getTokens() { return null; },
|
||||
});
|
||||
setAvailablePluginSyncProviderIdsImpl.call(manager, [pluginId]);
|
||||
assert.equal(manager.adapters.has(pluginId), false, 'must drop adapter when provider re-enters');
|
||||
assert.equal(signedOut >= 1, true);
|
||||
assert.equal(manager.state.providers[pluginId].status, 'connected');
|
||||
});
|
||||
|
||||
it('saveProviderConnection registers plugin IDs and disconnect unregisters them', async () => {
|
||||
const storage = memoryStorage();
|
||||
const manager = createManagerHarness(storage);
|
||||
manager.state = { providers: {} };
|
||||
|
||||
const connection: ProviderConnection = {
|
||||
provider: 'com.example.backup.sync',
|
||||
status: 'connected',
|
||||
// Non-secret plugin config: encryptProviderSecrets is a no-op without bridge.
|
||||
config: { endpoint: 'https://example.test' } as never,
|
||||
};
|
||||
await saveProviderConnectionImpl.call(manager, 'com.example.backup.sync', connection);
|
||||
assert.deepEqual(
|
||||
storage.get(SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS),
|
||||
['com.example.backup.sync'],
|
||||
);
|
||||
assert.ok(storage.get('netcatty_provider_plugin_v1:com.example.backup.sync'));
|
||||
|
||||
unregisterPluginProviderIdImpl.call(manager, 'com.example.backup.sync');
|
||||
assert.equal(storage.has(SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS), false);
|
||||
});
|
||||
|
||||
it('getConnectedAdapter uses createPluginStorage for namespaced provider IDs', async () => {
|
||||
const storage = memoryStorage();
|
||||
const manager = createManagerHarness(storage);
|
||||
const pluginId = 'com.example.backup.sync';
|
||||
const objectStore = new Map<string, Uint8Array>();
|
||||
let factoryCalls = 0;
|
||||
|
||||
manager.state = {
|
||||
providers: {
|
||||
[pluginId]: {
|
||||
provider: pluginId,
|
||||
status: 'connected',
|
||||
config: { endpoint: 'https://example.test' },
|
||||
},
|
||||
},
|
||||
};
|
||||
manager.providerDecrypted[pluginId] = true;
|
||||
manager.createPluginStorage = async (id: string): Promise<EncryptedObjectStorage> => {
|
||||
factoryCalls += 1;
|
||||
assert.equal(id, pluginId);
|
||||
return {
|
||||
providerId: id,
|
||||
async connect() {
|
||||
return { account: { id: 'plugin-acct' } };
|
||||
},
|
||||
async disconnect() {},
|
||||
async getAccount() {
|
||||
return { account: { id: 'plugin-acct' } };
|
||||
},
|
||||
async getCapabilities() {
|
||||
return {
|
||||
revisions: true,
|
||||
conditionalWrites: true,
|
||||
atomicReplacement: true,
|
||||
};
|
||||
},
|
||||
async readObject(key: string) {
|
||||
const bytes = objectStore.get(key) ?? null;
|
||||
return bytes
|
||||
? { found: true, key, bytes, revision: '1' }
|
||||
: { found: false, key, bytes: null };
|
||||
},
|
||||
async writeObject(key: string, bytes: Uint8Array) {
|
||||
const created = !objectStore.has(key);
|
||||
objectStore.set(key, bytes);
|
||||
return { created, revision: '2' };
|
||||
},
|
||||
async deleteObject(key: string) {
|
||||
return { deleted: objectStore.delete(key) };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const adapter = await getConnectedAdapterImpl.call(manager, pluginId);
|
||||
assert.equal(factoryCalls, 1);
|
||||
// Config-backed plugin connections report authenticated for cache reuse.
|
||||
assert.equal(adapter.isAuthenticated, true);
|
||||
const reused = await getConnectedAdapterImpl.call(manager, pluginId);
|
||||
assert.equal(reused, adapter);
|
||||
assert.equal(factoryCalls, 1, 'must not recreate when cached adapter is authenticated');
|
||||
|
||||
await adapter.initializeSync();
|
||||
assert.equal(adapter.accountInfo?.id, 'plugin-acct');
|
||||
|
||||
const file = makeSyncedFile(7, 'plugin-cipher');
|
||||
await adapter.upload(file);
|
||||
const downloaded = await adapter.download();
|
||||
assert.equal(downloaded?.payload, 'plugin-cipher');
|
||||
assert.equal(downloaded?.meta.version, 7);
|
||||
assert.ok(objectStore.has(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY));
|
||||
});
|
||||
|
||||
it('getConnectedAdapter accepts falsy scalar plugin configs as present', async () => {
|
||||
for (const scalar of [false, 0, ''] as const) {
|
||||
const storage = memoryStorage();
|
||||
const manager = createManagerHarness(storage);
|
||||
const pluginId = 'com.example.scalar.sync';
|
||||
let factoryCalls = 0;
|
||||
manager.state = {
|
||||
providers: {
|
||||
[pluginId]: {
|
||||
provider: pluginId,
|
||||
status: 'connected',
|
||||
config: scalar as never,
|
||||
},
|
||||
},
|
||||
};
|
||||
manager.providerDecrypted[pluginId] = true;
|
||||
manager.createPluginStorage = async (id: string): Promise<EncryptedObjectStorage> => {
|
||||
factoryCalls += 1;
|
||||
assert.equal(id, pluginId);
|
||||
return {
|
||||
providerId: id,
|
||||
async connect() {
|
||||
return { account: { id: 'scalar-acct' } };
|
||||
},
|
||||
async disconnect() {},
|
||||
async getAccount() {
|
||||
return { account: { id: 'scalar-acct' } };
|
||||
},
|
||||
async getCapabilities() {
|
||||
return {
|
||||
revisions: false,
|
||||
conditionalWrites: false,
|
||||
atomicReplacement: true,
|
||||
};
|
||||
},
|
||||
async readObject() {
|
||||
return { found: false, key: 'x', bytes: null };
|
||||
},
|
||||
async writeObject() {
|
||||
return { created: true, revision: '1' };
|
||||
},
|
||||
async deleteObject() {
|
||||
return { deleted: false };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const adapter = await getConnectedAdapterImpl.call(manager, pluginId);
|
||||
assert.equal(factoryCalls, 1);
|
||||
assert.equal(adapter.isAuthenticated, true);
|
||||
}
|
||||
|
||||
const empty = createManagerHarness(memoryStorage());
|
||||
empty.state = {
|
||||
providers: {
|
||||
'com.example.empty.sync': {
|
||||
provider: 'com.example.empty.sync',
|
||||
status: 'connected',
|
||||
},
|
||||
},
|
||||
};
|
||||
empty.providerDecrypted['com.example.empty.sync'] = true;
|
||||
await assert.rejects(
|
||||
() => getConnectedAdapterImpl.call(empty, 'com.example.empty.sync'),
|
||||
/Provider not connected/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAdapter WebDAV production path', () => {
|
||||
it('returns an authenticated adapter and reuses it via getConnectedAdapter cache semantics', async () => {
|
||||
const storage = memoryStorage();
|
||||
const manager = createManagerHarness(storage);
|
||||
manager.state = {
|
||||
providers: {
|
||||
webdav: {
|
||||
provider: 'webdav',
|
||||
status: 'connected',
|
||||
config: {
|
||||
endpoint: 'https://webdav.example.test',
|
||||
authType: 'basic',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
},
|
||||
resourceId: '/netcatty-vault.json',
|
||||
},
|
||||
},
|
||||
};
|
||||
manager.providerDecrypted.webdav = true;
|
||||
|
||||
const a1 = await getConnectedAdapterImpl.call(manager, 'webdav');
|
||||
assert.equal(a1.isAuthenticated, true);
|
||||
assert.equal(a1.resourceId, '/netcatty-vault.json');
|
||||
const a2 = await getConnectedAdapterImpl.call(manager, 'webdav');
|
||||
assert.equal(a1, a2, 'must reuse cached adapter when isAuthenticated is true');
|
||||
});
|
||||
|
||||
it('enforceLegacySingleProviderConnected keeps builtin and disconnects plugin', () => {
|
||||
const providers: Record<string, ProviderConnection> = {
|
||||
github: { provider: 'github', status: 'connected' },
|
||||
'com.example.backup.sync': {
|
||||
provider: 'com.example.backup.sync',
|
||||
status: 'connected',
|
||||
config: { endpoint: 'https://example.test' },
|
||||
},
|
||||
};
|
||||
enforceLegacySingleProviderConnected(providers);
|
||||
assert.equal(providers.github?.status, 'connected');
|
||||
assert.equal(providers['com.example.backup.sync']?.status, 'disconnected');
|
||||
assert.deepEqual(
|
||||
providers['com.example.backup.sync']?.config,
|
||||
{ endpoint: 'https://example.test' },
|
||||
);
|
||||
});
|
||||
});
|
||||
779
infrastructure/services/cloudSync/providerSyncMethods.ts
Normal file
779
infrastructure/services/cloudSync/providerSyncMethods.ts
Normal file
@@ -0,0 +1,779 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
|
||||
import packageJson from '../../../package.json';
|
||||
import { EncryptionService } from '../EncryptionService';
|
||||
import { mergeSyncPayloads } from '../../../domain/syncMerge';
|
||||
import { stripSyncPayloadEncryptedCredentials, healPoisonedSecretsForMerge } from '../../../domain/credentials';
|
||||
import { summarizeSyncChanges, withSyncReliabilityMeta } from '../../../domain/syncReliability';
|
||||
import { detectSuspiciousShrink, type ShrinkFinding } from '../../../domain/syncGuards';
|
||||
import { resolveCloudSyncConflictAction } from '../../../domain/syncStrategy';
|
||||
import { assertConvergentSyncWriteCompatible } from '../../../domain/convergentSync';
|
||||
import { getConvergentSyncLocalConfig } from '../convergentSyncConfig';
|
||||
import { syncAllProvidersConvergentlyImpl } from './convergentSyncRuntimeMethods';
|
||||
import type { CloudAdapter } from '../adapters';
|
||||
import type GitHubAdapter from '../adapters/GitHubAdapter';
|
||||
import type {
|
||||
CloudProvider,
|
||||
ConflictResolution,
|
||||
RemoteSyncPayload,
|
||||
SyncedFile,
|
||||
SyncFileMeta,
|
||||
SyncPayload,
|
||||
SyncResult,
|
||||
} from '../../../domain/sync';
|
||||
import { isConditionalWriteConflictError } from '../adapters/encryptedObjectStorageBridge';
|
||||
|
||||
function getSyncSecurityGeneration(manager: any): number | undefined {
|
||||
return typeof manager.getSyncSecurityGeneration === 'function'
|
||||
? manager.getSyncSecurityGeneration()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function assertSyncSecurityGeneration(manager: any, generation?: number): void {
|
||||
if (typeof manager.assertSyncSecurityGeneration === 'function') {
|
||||
manager.assertSyncSecurityGeneration(generation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Let the manager clear stale credentials when an error means a provider's
|
||||
* refresh token is dead (OneDrive). Returns true when it set a reconnect state,
|
||||
* so the caller skips the generic `error`-with-tokens status that would keep the
|
||||
* provider "ready" and retrying. Safe no-op when the manager lacks the hook.
|
||||
*/
|
||||
function handleProviderReauthRequired(
|
||||
manager: any,
|
||||
provider: CloudProvider,
|
||||
error: unknown,
|
||||
): boolean {
|
||||
return typeof manager.handleProviderReauthRequired === 'function'
|
||||
? manager.handleProviderReauthRequired(provider, error)
|
||||
: false;
|
||||
}
|
||||
|
||||
async function uploadLocalPayloadImpl(this: any,
|
||||
provider: CloudProvider,
|
||||
adapter: CloudAdapter,
|
||||
payload: SyncPayload,
|
||||
opts: { overrideShrink?: boolean },
|
||||
baseVersion: number,
|
||||
remoteFile?: SyncedFile | null,
|
||||
syncSecurityGeneration?: number,
|
||||
): Promise<SyncResult> {
|
||||
assertConvergentSyncWriteCompatible(remoteFile?.meta, payload);
|
||||
const overrideShrinkRequested = opts.overrideShrink === true;
|
||||
const directBase = await this.loadSyncBase(provider);
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
let directRemoteRef: SyncPayload | null = null;
|
||||
if (!directBase && remoteFile) {
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
try {
|
||||
directRemoteRef = await EncryptionService.decryptPayload(
|
||||
remoteFile,
|
||||
this.masterPassword,
|
||||
);
|
||||
} catch {
|
||||
directRemoteRef = null;
|
||||
}
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
}
|
||||
const metadataBase = directBase ?? directRemoteRef;
|
||||
const payloadForUpload = withSyncReliabilityMeta(payload, metadataBase, {
|
||||
deviceId: this.state.deviceId,
|
||||
now: Date.now(),
|
||||
});
|
||||
const directShrink = detectSuspiciousShrink(payloadForUpload, directBase, directRemoteRef);
|
||||
const shouldBlockDirect = directShrink.suspicious && !overrideShrinkRequested;
|
||||
const shouldForceDirect = directShrink.suspicious && overrideShrinkRequested;
|
||||
if (shouldBlockDirect) {
|
||||
this.state.syncState = 'BLOCKED';
|
||||
this.state.lastShrinkFinding = directShrink;
|
||||
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding: directShrink });
|
||||
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
shrinkBlocked: true,
|
||||
finding: directShrink,
|
||||
};
|
||||
}
|
||||
if (shouldForceDirect) {
|
||||
this.emit({ type: 'SYNC_FORCED', provider, finding: directShrink });
|
||||
}
|
||||
|
||||
const syncedFile = await EncryptionService.encryptPayload(
|
||||
payloadForUpload,
|
||||
this.masterPassword,
|
||||
this.state.deviceId,
|
||||
this.state.deviceName,
|
||||
packageJson.version,
|
||||
baseVersion,
|
||||
);
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
|
||||
const result = await this.uploadToProvider(provider, adapter, syncedFile, payloadForUpload, syncSecurityGeneration);
|
||||
|
||||
if (result.success) {
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.state.lastShrinkFinding = undefined;
|
||||
} else if (result.conflictDetected) {
|
||||
// Conflict UI / CONFLICT state already set by uploadToProvider.
|
||||
} else {
|
||||
this.state.syncState = 'ERROR';
|
||||
if (result.error) {
|
||||
this.state.lastError = result.error;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function downloadRemoteConflictPayloadImpl(this: any,
|
||||
provider: CloudProvider,
|
||||
remoteFile: SyncedFile,
|
||||
syncSecurityGeneration?: number,
|
||||
): Promise<SyncResult> {
|
||||
let remotePayload: SyncPayload;
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
try {
|
||||
remotePayload = await EncryptionService.decryptPayload(
|
||||
remoteFile,
|
||||
this.masterPassword,
|
||||
);
|
||||
} catch (decryptError) {
|
||||
throw new Error(`Decryption failed (master password may differ between devices): ${decryptError instanceof Error ? decryptError.message : String(decryptError)}`);
|
||||
}
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.state.lastError = null;
|
||||
this.updateProviderStatus(provider, 'connected');
|
||||
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
provider,
|
||||
action: 'download',
|
||||
version: remoteFile.meta.version,
|
||||
mergedPayload: remotePayload,
|
||||
remoteFile,
|
||||
};
|
||||
this.emit({ type: 'SYNC_COMPLETED', provider, result });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function uploadToProviderImpl(this: any,
|
||||
provider: CloudProvider,
|
||||
adapter: CloudAdapter,
|
||||
syncedFile: SyncedFile,
|
||||
payloadForBase?: SyncPayload,
|
||||
syncSecurityGeneration?: number,
|
||||
): Promise<SyncResult> {
|
||||
try {
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
const resourceId = await adapter.upload(syncedFile, {
|
||||
signal: this.activeSyncAbortSignal,
|
||||
});
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
this.state.lastError = null;
|
||||
|
||||
// Update local state (safe to do multiple times if values are same)
|
||||
this.state.localVersion = syncedFile.meta.version;
|
||||
this.state.localUpdatedAt = syncedFile.meta.updatedAt;
|
||||
this.state.remoteVersion = syncedFile.meta.version;
|
||||
this.state.remoteUpdatedAt = syncedFile.meta.updatedAt;
|
||||
// Invalidate any pending provider decrypt so it cannot overwrite
|
||||
// the lastSync/lastSyncVersion we are about to set.
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
...this.state.providers[provider],
|
||||
resourceId: resourceId || this.state.providers[provider].resourceId,
|
||||
lastSync: Date.now(),
|
||||
lastSyncVersion: syncedFile.meta.version,
|
||||
};
|
||||
|
||||
this.saveSyncConfig();
|
||||
// Persist base BEFORE anchor so a crash between them degrades
|
||||
// safely: the stale anchor forces re-inspection next run, which
|
||||
// merges against the fresh base and cannot silently drift.
|
||||
if (payloadForBase) {
|
||||
await this.saveSyncBase(payloadForBase, provider);
|
||||
}
|
||||
await this.saveSyncAnchor(provider, syncedFile, resourceId);
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.notifyStateChange();
|
||||
|
||||
// Add to sync history
|
||||
this.addSyncHistoryEntry({
|
||||
timestamp: Date.now(),
|
||||
provider,
|
||||
action: 'upload',
|
||||
success: true,
|
||||
localVersion: syncedFile.meta.version,
|
||||
remoteVersion: syncedFile.meta.version,
|
||||
deviceName: this.state.deviceName,
|
||||
});
|
||||
|
||||
this.updateProviderStatus(provider, 'connected');
|
||||
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
provider,
|
||||
action: 'upload',
|
||||
version: syncedFile.meta.version,
|
||||
};
|
||||
|
||||
this.emit({ type: 'SYNC_COMPLETED', provider, result });
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Conditional write rejected: surface as conflict so the user can pick
|
||||
// local vs remote instead of a generic sync failure.
|
||||
if (isConditionalWriteConflictError(error)) {
|
||||
try {
|
||||
const remoteFile = await adapter.download({
|
||||
signal: this.activeSyncAbortSignal,
|
||||
});
|
||||
if (remoteFile) {
|
||||
this.state.syncState = 'CONFLICT';
|
||||
this.state.currentConflict = {
|
||||
provider,
|
||||
localVersion: this.state.localVersion,
|
||||
localUpdatedAt: this.state.localUpdatedAt,
|
||||
localDeviceName: this.state.deviceName,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
remoteUpdatedAt: remoteFile.meta.updatedAt,
|
||||
remoteDeviceName: remoteFile.meta.deviceName,
|
||||
};
|
||||
this.emit({
|
||||
type: 'CONFLICT_DETECTED',
|
||||
conflict: this.state.currentConflict,
|
||||
});
|
||||
// Leave the provider ready (not stuck on "syncing") while the user resolves.
|
||||
this.updateProviderStatus(provider, 'connected');
|
||||
this.addSyncHistoryEntry({
|
||||
timestamp: Date.now(),
|
||||
provider,
|
||||
action: 'upload',
|
||||
success: false,
|
||||
localVersion: this.state.localVersion,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
deviceName: this.state.deviceName,
|
||||
error: String(error),
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
conflictDetected: true,
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to generic ERROR if we cannot load remote for the UI.
|
||||
}
|
||||
}
|
||||
|
||||
this.state.lastError = String(error);
|
||||
if (!handleProviderReauthRequired(this, provider, error)) {
|
||||
this.updateProviderStatus(provider, 'error', String(error));
|
||||
}
|
||||
|
||||
// Add to sync history
|
||||
this.addSyncHistoryEntry({
|
||||
timestamp: Date.now(),
|
||||
provider,
|
||||
action: 'upload',
|
||||
success: false,
|
||||
localVersion: this.state.localVersion,
|
||||
deviceName: this.state.deviceName,
|
||||
error: String(error),
|
||||
});
|
||||
|
||||
this.emit({ type: 'SYNC_ERROR', provider, error: String(error) });
|
||||
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPayloadImpl(this: any,data: {
|
||||
hosts: SyncPayload['hosts'];
|
||||
keys: SyncPayload['keys'];
|
||||
proxyProfiles?: SyncPayload['proxyProfiles'];
|
||||
snippets: SyncPayload['snippets'];
|
||||
customGroups: SyncPayload['customGroups'];
|
||||
snippetPackages?: SyncPayload['snippetPackages'];
|
||||
portForwardingRules?: SyncPayload['portForwardingRules'];
|
||||
settings?: SyncPayload['settings'];
|
||||
}): SyncPayload {
|
||||
return {
|
||||
...data,
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectConvergentSyncToProviderResult(
|
||||
provider: CloudProvider,
|
||||
results: Map<CloudProvider, SyncResult>,
|
||||
): SyncResult {
|
||||
const requested = results.get(provider) ?? {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
error: 'Provider is not available for convergent sync',
|
||||
} satisfies SyncResult;
|
||||
if (requested.mergedPayload) return requested;
|
||||
|
||||
// Convergent sync fans out to every provider. A non-target provider can
|
||||
// verify a newer joined replica even when the requested provider fails;
|
||||
// preserve that aggregate payload so the caller can update the local vault
|
||||
// before reporting the requested provider's failure.
|
||||
const aggregateMergedPayload = [...results.values()]
|
||||
.find((result) => result.mergedPayload);
|
||||
return aggregateMergedPayload?.mergedPayload
|
||||
? {
|
||||
...requested,
|
||||
mergedPayload: aggregateMergedPayload.mergedPayload,
|
||||
...(aggregateMergedPayload.mergedPayloadApplied
|
||||
? { mergedPayloadApplied: true }
|
||||
: {}),
|
||||
}
|
||||
: requested;
|
||||
}
|
||||
|
||||
export async function syncToProviderImpl(this: any,
|
||||
provider: CloudProvider,
|
||||
payload: SyncPayload,
|
||||
opts: {
|
||||
overrideShrink?: boolean;
|
||||
applyConvergentPayload?: (
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>;
|
||||
} = {},
|
||||
): Promise<SyncResult> {
|
||||
const convergentConfig = getConvergentSyncLocalConfig();
|
||||
if (convergentConfig.initialized) {
|
||||
if (!convergentConfig.enabled) {
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
error: 'Convergent sync is paused on this device',
|
||||
};
|
||||
}
|
||||
const results = await syncAllProvidersConvergentlyImpl.call(this, payload, {
|
||||
overrideShrink: opts.overrideShrink,
|
||||
applyPayload: opts.applyConvergentPayload,
|
||||
});
|
||||
return selectConvergentSyncToProviderResult(provider, results);
|
||||
}
|
||||
|
||||
if (this.state.securityState !== 'UNLOCKED') {
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
error: 'Vault is locked',
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.masterPassword) {
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
error: 'Master password not available',
|
||||
};
|
||||
}
|
||||
|
||||
const overrideShrinkRequested = opts.overrideShrink === true;
|
||||
const syncSecurityGeneration = getSyncSecurityGeneration(this);
|
||||
|
||||
let adapter: CloudAdapter;
|
||||
try {
|
||||
adapter = await this.getConnectedAdapter(provider);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
error: 'Provider not connected',
|
||||
};
|
||||
}
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
|
||||
this.updateProviderStatus(provider, 'syncing');
|
||||
this.state.lastError = null;
|
||||
this.state.syncState = 'SYNCING';
|
||||
this.emit({ type: 'SYNC_STARTED', provider });
|
||||
|
||||
try {
|
||||
// 1. Check for conflict. `checkProviderConflict` throws on
|
||||
// inspect failure, which the outer try/catch routes to the
|
||||
// SYNC_ERROR path — so we never reach the upload branch with an
|
||||
// unknown remote state.
|
||||
const checkResult = await this.checkProviderConflict(provider, adapter);
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
|
||||
if (checkResult.conflict && checkResult.remoteFile) {
|
||||
const conflictAction = resolveCloudSyncConflictAction(this.state.syncStrategy, {
|
||||
hasConflict: checkResult.conflict,
|
||||
hasRemoteFile: Boolean(checkResult.remoteFile),
|
||||
});
|
||||
|
||||
if (conflictAction === 'download-remote') {
|
||||
return await downloadRemoteConflictPayloadImpl.call(
|
||||
this,
|
||||
provider,
|
||||
checkResult.remoteFile,
|
||||
syncSecurityGeneration,
|
||||
);
|
||||
}
|
||||
|
||||
if (conflictAction === 'upload-local') {
|
||||
return await uploadLocalPayloadImpl.call(
|
||||
this,
|
||||
provider,
|
||||
adapter,
|
||||
payload,
|
||||
opts,
|
||||
checkResult.remoteFile.meta.version,
|
||||
checkResult.remoteFile,
|
||||
syncSecurityGeneration,
|
||||
);
|
||||
}
|
||||
|
||||
let remotePayloadForConflict: SyncPayload | null = null;
|
||||
let baseForConflict: SyncPayload | null = null;
|
||||
|
||||
// Remote is newer — attempt three-way merge instead of blocking
|
||||
try {
|
||||
let remotePayload: SyncPayload;
|
||||
try {
|
||||
remotePayload = await EncryptionService.decryptPayload(
|
||||
checkResult.remoteFile,
|
||||
this.masterPassword,
|
||||
);
|
||||
remotePayloadForConflict = remotePayload;
|
||||
} catch (decryptError) {
|
||||
throw new Error(`Decryption failed (master password may differ between devices): ${decryptError instanceof Error ? decryptError.message : String(decryptError)}`);
|
||||
}
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
const base = await this.loadSyncBase(provider);
|
||||
baseForConflict = base;
|
||||
const localHealed = healPoisonedSecretsForMerge(payload, remotePayload, base);
|
||||
remotePayload = healPoisonedSecretsForMerge(remotePayload, payload, base);
|
||||
remotePayloadForConflict = remotePayload;
|
||||
const mergeResult = mergeSyncPayloads(base, localHealed, remotePayload);
|
||||
const mergedPayload = withSyncReliabilityMeta(
|
||||
stripSyncPayloadEncryptedCredentials(mergeResult.payload),
|
||||
base,
|
||||
{
|
||||
deviceId: this.state.deviceId,
|
||||
now: Date.now(),
|
||||
},
|
||||
);
|
||||
assertConvergentSyncWriteCompatible(checkResult.remoteFile.meta, mergedPayload);
|
||||
|
||||
console.info('[CloudSyncManager] Three-way merge completed', mergeResult.summary);
|
||||
|
||||
// Shrink guard: refuse to push a merged payload that silently deletes
|
||||
// entities we still have in base. The merge itself is correct if local
|
||||
// state is trustworthy — but a degraded local (keychain failure,
|
||||
// partial load) can make merge produce a smaller-than-expected result.
|
||||
const mergedShrink = detectSuspiciousShrink(mergedPayload, base, remotePayload);
|
||||
const shouldBlockMerged = mergedShrink.suspicious && !overrideShrinkRequested;
|
||||
const shouldForceMerged = mergedShrink.suspicious && overrideShrinkRequested;
|
||||
if (shouldBlockMerged) {
|
||||
this.state.syncState = 'BLOCKED';
|
||||
this.state.lastShrinkFinding = mergedShrink;
|
||||
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding: mergedShrink });
|
||||
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
shrinkBlocked: true,
|
||||
finding: mergedShrink,
|
||||
};
|
||||
}
|
||||
if (shouldForceMerged) {
|
||||
this.emit({ type: 'SYNC_FORCED', provider, finding: mergedShrink });
|
||||
}
|
||||
|
||||
// Encrypt and upload merged payload
|
||||
const mergedSyncedFile = await EncryptionService.encryptPayload(
|
||||
mergedPayload,
|
||||
this.masterPassword,
|
||||
this.state.deviceId,
|
||||
this.state.deviceName,
|
||||
packageJson.version,
|
||||
checkResult.remoteFile.meta.version, // base on remote version
|
||||
);
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
|
||||
const uploadResult = await this.uploadToProvider(
|
||||
provider,
|
||||
adapter,
|
||||
mergedSyncedFile,
|
||||
mergedPayload,
|
||||
syncSecurityGeneration,
|
||||
);
|
||||
|
||||
if (uploadResult.success) {
|
||||
// Base was persisted inside uploadToProvider before the
|
||||
// anchor advanced, so a crash between them cannot leave a
|
||||
// stale base pointing at pre-merge state.
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
|
||||
this.addSyncHistoryEntry({
|
||||
timestamp: Date.now(),
|
||||
provider,
|
||||
action: 'merge',
|
||||
success: true,
|
||||
localVersion: mergedSyncedFile.meta.version,
|
||||
remoteVersion: checkResult.remoteFile.meta.version,
|
||||
deviceName: this.state.deviceName,
|
||||
});
|
||||
|
||||
return {
|
||||
...uploadResult,
|
||||
action: 'merge',
|
||||
mergedPayload,
|
||||
};
|
||||
}
|
||||
|
||||
// Upload after merge failed — preserve CONFLICT when conditional write
|
||||
// already surfaced a conflict; otherwise mark ERROR so we leave SYNCING.
|
||||
if (!uploadResult.conflictDetected) {
|
||||
this.state.syncState = 'ERROR';
|
||||
this.state.lastError = uploadResult.error || 'Upload failed after merge';
|
||||
}
|
||||
return uploadResult;
|
||||
} catch (mergeError) {
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
// Merge failed — fall back to conflict UI
|
||||
console.error('[CloudSyncManager] Merge failed, falling back to conflict UI', mergeError);
|
||||
const remoteFile = checkResult.remoteFile;
|
||||
this.state.syncState = 'CONFLICT';
|
||||
this.state.currentConflict = {
|
||||
provider,
|
||||
localVersion: this.state.localVersion,
|
||||
localUpdatedAt: this.state.localUpdatedAt,
|
||||
localDeviceName: this.state.deviceName,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
remoteUpdatedAt: remoteFile.meta.updatedAt,
|
||||
remoteDeviceName: remoteFile.meta.deviceName,
|
||||
...(remotePayloadForConflict
|
||||
? { changeSummary: summarizeSyncChanges(baseForConflict, payload, remotePayloadForConflict) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
this.emit({
|
||||
type: 'CONFLICT_DETECTED',
|
||||
conflict: this.state.currentConflict,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
conflictDetected: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return await uploadLocalPayloadImpl.call(
|
||||
this,
|
||||
provider,
|
||||
adapter,
|
||||
payload,
|
||||
opts,
|
||||
this.state.localVersion,
|
||||
checkResult.remoteFile,
|
||||
syncSecurityGeneration,
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
this.state.syncState = 'ERROR';
|
||||
this.state.lastError = String(error);
|
||||
// A dead OneDrive refresh token clears its own credentials and sets a
|
||||
// clean reconnect status; only fall back to the generic error status when
|
||||
// it wasn't a reauth-required failure.
|
||||
if (!handleProviderReauthRequired(this, provider, error)) {
|
||||
this.updateProviderStatus(provider, 'error', String(error));
|
||||
}
|
||||
|
||||
// Add to sync history
|
||||
this.addSyncHistoryEntry({
|
||||
timestamp: Date.now(),
|
||||
provider,
|
||||
action: 'upload',
|
||||
success: false,
|
||||
localVersion: this.state.localVersion,
|
||||
deviceName: this.state.deviceName,
|
||||
error: String(error),
|
||||
});
|
||||
|
||||
this.emit({ type: 'SYNC_ERROR', provider, error: String(error) });
|
||||
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadFromProviderImpl(this: any,provider: CloudProvider): Promise<RemoteSyncPayload | null> {
|
||||
if (this.state.securityState !== 'UNLOCKED' || !this.masterPassword) {
|
||||
throw new Error('Vault is locked');
|
||||
}
|
||||
|
||||
const adapter = await this.getConnectedAdapter(provider);
|
||||
|
||||
try {
|
||||
let remoteFile: SyncedFile | null;
|
||||
try {
|
||||
remoteFile = await adapter.download({
|
||||
signal: this.activeSyncAbortSignal,
|
||||
});
|
||||
} catch (downloadError) {
|
||||
throw new Error(`Download failed: ${downloadError instanceof Error ? downloadError.message : String(downloadError)}`);
|
||||
}
|
||||
if (!remoteFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
let payload: SyncPayload;
|
||||
try {
|
||||
payload = await EncryptionService.decryptPayload(remoteFile, this.masterPassword);
|
||||
} catch (decryptError) {
|
||||
throw new Error(`Decryption failed (master password may differ between devices): ${decryptError instanceof Error ? decryptError.message : String(decryptError)}`);
|
||||
}
|
||||
|
||||
return { provider, payload, remoteFile };
|
||||
} catch (error) {
|
||||
// Surface a reconnect state if the failure was a dead OneDrive refresh
|
||||
// token (clears stale credentials); the error is still rethrown to the
|
||||
// caller below.
|
||||
handleProviderReauthRequired(this, provider, error);
|
||||
// Add to sync history
|
||||
this.addSyncHistoryEntry({
|
||||
timestamp: Date.now(),
|
||||
provider,
|
||||
action: 'download',
|
||||
success: false,
|
||||
localVersion: this.state.localVersion,
|
||||
error: String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGistRevisionHistoryImpl(this: any): Promise<Array<{ version: string; date: Date }>> {
|
||||
let adapter: GitHubAdapter;
|
||||
try {
|
||||
adapter = await this.getConnectedAdapter('github') as GitHubAdapter;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!adapter.getHistory) return [];
|
||||
return adapter.getHistory();
|
||||
}
|
||||
|
||||
export async function downloadGistRevisionImpl(this: any,sha: string): Promise<{
|
||||
payload: SyncPayload;
|
||||
meta: SyncFileMeta;
|
||||
preview: {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
noteCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
} | null> {
|
||||
if (this.state.securityState !== 'UNLOCKED' || !this.masterPassword) {
|
||||
throw new Error('Vault is locked');
|
||||
}
|
||||
let adapter: GitHubAdapter;
|
||||
try {
|
||||
adapter = await this.getConnectedAdapter('github') as GitHubAdapter;
|
||||
} catch {
|
||||
throw new Error('GitHub adapter not available');
|
||||
}
|
||||
if (!adapter.downloadRevision) throw new Error('GitHub adapter not available');
|
||||
const syncedFile = await adapter.downloadRevision(sha);
|
||||
if (!syncedFile) return null;
|
||||
|
||||
const payload = await EncryptionService.decryptPayload(syncedFile, this.masterPassword);
|
||||
return {
|
||||
payload,
|
||||
meta: syncedFile.meta,
|
||||
preview: {
|
||||
hostCount: payload.hosts?.length ?? 0,
|
||||
keyCount: payload.keys?.length ?? 0,
|
||||
snippetCount: payload.snippets?.length ?? 0,
|
||||
noteCount: payload.notes?.length ?? 0,
|
||||
identityCount: payload.identities?.length ?? 0,
|
||||
portForwardingRuleCount: payload.portForwardingRules?.length ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveConflictImpl(this: any,resolution: ConflictResolution): Promise<RemoteSyncPayload | null> {
|
||||
if (!this.state.currentConflict) {
|
||||
throw new Error('No conflict to resolve');
|
||||
}
|
||||
|
||||
const { provider } = this.state.currentConflict;
|
||||
this.emit({ type: 'CONFLICT_RESOLVED', resolution });
|
||||
|
||||
if (resolution === 'USE_REMOTE') {
|
||||
// Download and return remote data
|
||||
const payload = await this.downloadFromProvider(provider);
|
||||
this.state.currentConflict = null;
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.notifyStateChange(); // Notify UI of conflict resolution
|
||||
return payload;
|
||||
} else {
|
||||
// USE_LOCAL - just clear conflict, caller will re-sync
|
||||
this.state.currentConflict = null;
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.notifyStateChange(); // Notify UI of conflict resolution
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function exitBlockedStateImpl(this: any): void {
|
||||
if (this.state.syncState === 'BLOCKED') {
|
||||
this.state.lastShrinkFinding = undefined;
|
||||
this.emit({ type: 'SYNC_BLOCKED_CLEARED' });
|
||||
}
|
||||
}
|
||||
|
||||
export function clearShrinkBlockedStateImpl(this: any): void {
|
||||
if (this.state.syncState === 'BLOCKED') {
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.notifyStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
export function getShrinkBlockedFindingImpl(this: any): Extract<ShrinkFinding, { suspicious: true }> | null {
|
||||
if (this.state.syncState !== 'BLOCKED') return null;
|
||||
return this.state.lastShrinkFinding ?? null;
|
||||
}
|
||||
1041
infrastructure/services/cloudSync/stateAndSecurityMethods.ts
Normal file
1041
infrastructure/services/cloudSync/stateAndSecurityMethods.ts
Normal file
File diff suppressed because it is too large
Load Diff
908
infrastructure/services/cloudSync/syncAllStorageMethods.test.ts
Normal file
908
infrastructure/services/cloudSync/syncAllStorageMethods.test.ts
Normal file
@@ -0,0 +1,908 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { EncryptionService } from "../EncryptionService.ts";
|
||||
import {
|
||||
clearProviderMergeStateImpl,
|
||||
commitRemoteInspectionImpl,
|
||||
} from "./authMethods.ts";
|
||||
import {
|
||||
selectConvergentSyncToProviderResult,
|
||||
syncToProviderImpl,
|
||||
uploadToProviderImpl,
|
||||
} from "./providerSyncMethods.ts";
|
||||
import {
|
||||
clearSyncBaseImpl,
|
||||
loadSyncSnapshotsImpl,
|
||||
saveSyncBaseImpl,
|
||||
syncAllProvidersImpl,
|
||||
} from "./syncAllStorageMethods.ts";
|
||||
import type {
|
||||
CloudProvider,
|
||||
SyncedFile,
|
||||
SyncPayload,
|
||||
SyncResult,
|
||||
} from "../../../domain/sync.ts";
|
||||
import { setConvergentSyncLocalConfig } from "../convergentSyncConfig.ts";
|
||||
|
||||
function payload(hostId: string): SyncPayload {
|
||||
return payloadWithHosts([hostId]);
|
||||
}
|
||||
|
||||
function payloadWithHosts(hostIds: string[]): SyncPayload {
|
||||
return {
|
||||
hosts: hostIds.map((hostId) => ({
|
||||
id: hostId,
|
||||
label: hostId,
|
||||
hostname: `${hostId}.example.com`,
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
})),
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
snippetPackages: [],
|
||||
portForwardingRules: [],
|
||||
groupConfigs: [],
|
||||
settings: undefined,
|
||||
syncedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function remoteFile(provider: CloudProvider, version: number, updatedAt: number): SyncedFile {
|
||||
return {
|
||||
meta: {
|
||||
version,
|
||||
updatedAt,
|
||||
deviceId: `${provider}-device`,
|
||||
deviceName: provider,
|
||||
appVersion: "0.0.0",
|
||||
iv: "",
|
||||
salt: "",
|
||||
algorithm: "AES-256-GCM",
|
||||
kdf: "PBKDF2",
|
||||
kdfIterations: 1,
|
||||
},
|
||||
payload: provider,
|
||||
};
|
||||
}
|
||||
|
||||
test("provider identity changes clear v1 base, v2 baseline, and remote anchor together", () => {
|
||||
const removed: string[] = [];
|
||||
const manager = {
|
||||
syncBaseKey: (provider: CloudProvider) => `base:${provider}`,
|
||||
convergentProviderBaselineKey: (provider: CloudProvider) => `convergent:${provider}`,
|
||||
removeFromStorage: (key: string) => removed.push(key),
|
||||
clearSyncAnchor: (provider: CloudProvider) => removed.push(`anchor:${provider}`),
|
||||
};
|
||||
|
||||
clearProviderMergeStateImpl.call(manager, "github");
|
||||
|
||||
assert.deepEqual(removed, ["base:github", "convergent:github", "anchor:github"]);
|
||||
});
|
||||
|
||||
test("clearing all merge bases also removes every convergent provider baseline", () => {
|
||||
const removed = new Set<string>();
|
||||
const providers: CloudProvider[] = ["github", "google", "onedrive", "webdav", "s3"];
|
||||
const manager = {
|
||||
removeFromStorage: (key: string) => removed.add(key),
|
||||
syncBaseKey: (provider?: CloudProvider) => `base:${provider ?? "default"}`,
|
||||
syncSnapshotsKey: (provider?: CloudProvider) => `snapshots:${provider ?? "default"}`,
|
||||
convergentProviderBaselineKey: (provider: CloudProvider) => `convergent:${provider}`,
|
||||
clearSyncAnchor: () => {},
|
||||
};
|
||||
|
||||
clearSyncBaseImpl.call(manager);
|
||||
|
||||
for (const provider of providers) {
|
||||
assert.equal(removed.has(`convergent:${provider}`), true);
|
||||
}
|
||||
});
|
||||
|
||||
test("syncAllProviders uses the newest cloud payload without merging other remotes when cloud wins", async () => {
|
||||
const originalDecryptPayload = EncryptionService.decryptPayload;
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
|
||||
const githubRemote = remoteFile("github", 3, 300);
|
||||
const googleRemote = remoteFile("google", 2, 200);
|
||||
const githubPayload = payload("github-winner");
|
||||
const localPayload = payload("local");
|
||||
const uploaded: Array<{ provider: CloudProvider; payload: SyncPayload }> = [];
|
||||
const committed: CloudProvider[] = [];
|
||||
|
||||
EncryptionService.decryptPayload = async (file: SyncedFile) => {
|
||||
if (file === githubRemote) return githubPayload;
|
||||
return payload("google-loser");
|
||||
};
|
||||
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
|
||||
...remoteFile("github", 4, 400),
|
||||
payload: JSON.stringify(outgoing),
|
||||
});
|
||||
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "pw",
|
||||
adapters: new Map(),
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "connected" },
|
||||
google: { enabled: true, connected: true, status: "connected" },
|
||||
onedrive: { enabled: false, connected: false, status: "disconnected" },
|
||||
webdav: { enabled: false, connected: false, status: "disconnected" },
|
||||
s3: { enabled: false, connected: false, status: "disconnected" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "preferCloud",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getConnectedAdapter: async (provider: CloudProvider) => ({ provider }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async (provider: CloudProvider) => ({
|
||||
conflict: true,
|
||||
remoteFile: provider === "github" ? githubRemote : googleRemote,
|
||||
}),
|
||||
loadSyncBase: async () => payload("base"),
|
||||
commitRemoteInspection: async (provider: CloudProvider) => {
|
||||
committed.push(provider);
|
||||
},
|
||||
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
|
||||
uploaded.push({ provider, payload: outgoing });
|
||||
return { success: true, provider, action: "upload" as const, version: 4 };
|
||||
},
|
||||
exitBlockedState: () => {},
|
||||
notifyStateChange: () => {},
|
||||
};
|
||||
|
||||
const results = await syncAllProvidersImpl.call(manager, localPayload);
|
||||
|
||||
assert.equal(results.get("github")?.action, "download");
|
||||
assert.deepEqual(results.get("github")?.mergedPayload, githubPayload);
|
||||
assert.equal(results.get("github")?.remoteFile, githubRemote);
|
||||
assert.equal(uploaded.length, 1);
|
||||
assert.equal(uploaded[0].provider, "google");
|
||||
assert.equal(uploaded[0].payload.hosts[0]?.id, "github-winner");
|
||||
assert.equal(uploaded[0].payload.syncMeta?.schemaVersion, 1);
|
||||
assert.deepEqual(committed, []);
|
||||
} finally {
|
||||
EncryptionService.decryptPayload = originalDecryptPayload;
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
|
||||
test("syncToProvider uses the checked remote as metadata base when no stored base exists", async () => {
|
||||
const originalDecryptPayload = EncryptionService.decryptPayload;
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
const checkedRemote = remoteFile("github", 3, 300);
|
||||
const remotePayload = payloadWithHosts(["kept", "deleted-on-local"]);
|
||||
const localPayload = payload("kept");
|
||||
let uploadedPayload: SyncPayload | undefined;
|
||||
|
||||
EncryptionService.decryptPayload = async (file: SyncedFile) => {
|
||||
assert.equal(file, checkedRemote);
|
||||
return remotePayload;
|
||||
};
|
||||
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
|
||||
...remoteFile("github", 4, 400),
|
||||
payload: JSON.stringify(outgoing),
|
||||
});
|
||||
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "pw",
|
||||
adapters: new Map(),
|
||||
providerDecryptSeq: { github: 0 },
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "connected" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "smartMerge",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getConnectedAdapter: async () => ({ provider: "github" }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async () => ({ conflict: false, remoteFile: checkedRemote }),
|
||||
loadSyncBase: async () => null,
|
||||
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
|
||||
uploadedPayload = outgoing;
|
||||
return { success: true, provider, action: "upload" as const, version: 4 };
|
||||
},
|
||||
exitBlockedState: () => {},
|
||||
};
|
||||
|
||||
const result = await syncToProviderImpl.call(manager, "github", localPayload);
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.deepEqual(uploadedPayload?.syncMeta?.deletions, [{
|
||||
entityType: "hosts",
|
||||
id: "deleted-on-local",
|
||||
deletedAt: uploadedPayload?.syncMeta?.generatedAt,
|
||||
deviceId: "local-device",
|
||||
}]);
|
||||
} finally {
|
||||
EncryptionService.decryptPayload = originalDecryptPayload;
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
|
||||
test("syncToProvider refuses to downgrade a checked convergent remote", async () => {
|
||||
const checkedRemote = remoteFile("github", 3, 300);
|
||||
checkedRemote.meta.syncSchemaVersion = 2;
|
||||
let encrypted = false;
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
EncryptionService.encryptPayload = async () => {
|
||||
encrypted = true;
|
||||
return checkedRemote;
|
||||
};
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "pw",
|
||||
adapters: new Map(),
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: { github: { status: "connected" } },
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "smartMerge",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getConnectedAdapter: async () => ({ provider: "github" }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async () => ({ conflict: false, remoteFile: checkedRemote }),
|
||||
addSyncHistoryEntry: () => {},
|
||||
};
|
||||
|
||||
const result = await syncToProviderImpl.call(manager, "github", payload("local"));
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(encrypted, false);
|
||||
assert.match(result.error ?? "", /Enable or migrate convergent sync/);
|
||||
} finally {
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
|
||||
test("syncToProvider aborts an upload when the master key changes after encryption", async () => {
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
const localPayload = payload("local");
|
||||
let generation = 0;
|
||||
let uploaded = false;
|
||||
|
||||
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => {
|
||||
generation += 1;
|
||||
return {
|
||||
...remoteFile("github", 2, 200),
|
||||
payload: JSON.stringify(outgoing),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "old-master-password",
|
||||
adapters: new Map(),
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "connected" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "smartMerge",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getSyncSecurityGeneration: () => 0,
|
||||
assertSyncSecurityGeneration: (expected: number) => {
|
||||
if (generation !== expected) {
|
||||
throw new Error("Sync cancelled because master key changed");
|
||||
}
|
||||
},
|
||||
getConnectedAdapter: async () => ({ provider: "github" }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async () => ({ conflict: false }),
|
||||
loadSyncBase: async () => null,
|
||||
uploadToProvider: async (provider: CloudProvider) => {
|
||||
uploaded = true;
|
||||
return { success: true, provider, action: "upload" as const, version: 2 };
|
||||
},
|
||||
exitBlockedState: () => {},
|
||||
addSyncHistoryEntry: () => {},
|
||||
};
|
||||
|
||||
const result = await syncToProviderImpl.call(manager, "github", localPayload);
|
||||
|
||||
assert.equal(uploaded, false);
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error ?? "", /master key changed/);
|
||||
} finally {
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
|
||||
test("uploadToProvider skips local commits when the master key changes during upload", async () => {
|
||||
let generation = 0;
|
||||
let savedAnchor = false;
|
||||
let savedBase = false;
|
||||
let savedProvider = false;
|
||||
const file = remoteFile("github", 2, 200);
|
||||
|
||||
const manager = {
|
||||
providerDecryptSeq: { github: 0 },
|
||||
state: {
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "syncing" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "SYNCING",
|
||||
localVersion: 1,
|
||||
localUpdatedAt: 100,
|
||||
remoteVersion: 1,
|
||||
remoteUpdatedAt: 100,
|
||||
deviceName: "Local",
|
||||
},
|
||||
assertSyncSecurityGeneration: (expected: number) => {
|
||||
if (generation !== expected) {
|
||||
throw new Error("Sync cancelled because master key changed");
|
||||
}
|
||||
},
|
||||
saveSyncConfig: () => {},
|
||||
saveSyncBase: async () => {
|
||||
savedBase = true;
|
||||
},
|
||||
saveSyncAnchor: async () => {
|
||||
savedAnchor = true;
|
||||
},
|
||||
saveProviderConnection: async () => {
|
||||
savedProvider = true;
|
||||
},
|
||||
notifyStateChange: () => {},
|
||||
addSyncHistoryEntry: () => {},
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
};
|
||||
|
||||
const adapter = {
|
||||
upload: async () => {
|
||||
generation += 1;
|
||||
return "resource-id";
|
||||
},
|
||||
};
|
||||
|
||||
const result = await uploadToProviderImpl.call(
|
||||
manager,
|
||||
"github",
|
||||
adapter,
|
||||
file,
|
||||
payload("local"),
|
||||
0,
|
||||
);
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error ?? "", /master key changed/);
|
||||
assert.equal(savedBase, false);
|
||||
assert.equal(savedAnchor, false);
|
||||
assert.equal(savedProvider, false);
|
||||
});
|
||||
|
||||
test("syncAllProviders uses the checked remote as metadata base when provider base is missing", async () => {
|
||||
const originalDecryptPayload = EncryptionService.decryptPayload;
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
const checkedRemote = remoteFile("github", 3, 300);
|
||||
const remotePayload = payloadWithHosts(["kept", "deleted-on-local"]);
|
||||
const localPayload = payload("kept");
|
||||
let uploadedPayload: SyncPayload | undefined;
|
||||
|
||||
EncryptionService.decryptPayload = async (file: SyncedFile) => {
|
||||
assert.equal(file, checkedRemote);
|
||||
return remotePayload;
|
||||
};
|
||||
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
|
||||
...remoteFile("github", 4, 400),
|
||||
payload: JSON.stringify(outgoing),
|
||||
});
|
||||
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "pw",
|
||||
adapters: new Map(),
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "connected" },
|
||||
google: { enabled: false, connected: false, status: "disconnected" },
|
||||
onedrive: { enabled: false, connected: false, status: "disconnected" },
|
||||
webdav: { enabled: false, connected: false, status: "disconnected" },
|
||||
s3: { enabled: false, connected: false, status: "disconnected" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "smartMerge",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getConnectedAdapter: async () => ({ provider: "github" }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async () => ({ conflict: false, remoteFile: checkedRemote }),
|
||||
loadSyncBase: async () => null,
|
||||
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
|
||||
uploadedPayload = outgoing;
|
||||
return { success: true, provider, action: "upload" as const, version: 4 };
|
||||
},
|
||||
exitBlockedState: () => {},
|
||||
notifyStateChange: () => {},
|
||||
};
|
||||
|
||||
const results = await syncAllProvidersImpl.call(manager, localPayload);
|
||||
|
||||
assert.equal(results.get("github")?.success, true);
|
||||
assert.deepEqual(uploadedPayload?.syncMeta?.deletions, [{
|
||||
entityType: "hosts",
|
||||
id: "deleted-on-local",
|
||||
deletedAt: uploadedPayload?.syncMeta?.generatedAt,
|
||||
deviceId: "local-device",
|
||||
}]);
|
||||
} finally {
|
||||
EncryptionService.decryptPayload = originalDecryptPayload;
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
|
||||
test("commitRemoteInspection saves the comparison base before advancing the remote anchor", async () => {
|
||||
const calls: string[] = [];
|
||||
const file = remoteFile("github", 5, 500);
|
||||
const incoming = payload("cloud");
|
||||
const manager = {
|
||||
providerDecryptSeq: { github: 0 },
|
||||
state: {
|
||||
providers: {
|
||||
github: { resourceId: "old", lastSync: 0, lastSyncVersion: 0 },
|
||||
},
|
||||
localVersion: 0,
|
||||
localUpdatedAt: 0,
|
||||
remoteVersion: 0,
|
||||
remoteUpdatedAt: 0,
|
||||
},
|
||||
getConnectedAdapter: async () => ({ resourceId: "remote-resource" }),
|
||||
saveSyncConfig: () => calls.push("config"),
|
||||
saveSyncBase: async () => calls.push("base"),
|
||||
saveSyncAnchor: async () => calls.push("anchor"),
|
||||
saveProviderConnection: async () => calls.push("connection"),
|
||||
addSyncHistoryEntry: () => calls.push("history"),
|
||||
notifyStateChange: () => calls.push("notify"),
|
||||
};
|
||||
|
||||
await commitRemoteInspectionImpl.call(manager, "github", file, incoming, {
|
||||
recordDownload: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ["base", "config", "anchor", "connection", "history", "notify"]);
|
||||
});
|
||||
|
||||
test("commitRemoteInspection does not advance the remote anchor when saving the base fails", async () => {
|
||||
const calls: string[] = [];
|
||||
const manager = {
|
||||
providerDecryptSeq: { github: 0 },
|
||||
state: {
|
||||
providers: {
|
||||
github: { resourceId: "remote-resource", lastSync: 0, lastSyncVersion: 0 },
|
||||
},
|
||||
localVersion: 0,
|
||||
localUpdatedAt: 0,
|
||||
remoteVersion: 0,
|
||||
remoteUpdatedAt: 0,
|
||||
},
|
||||
getConnectedAdapter: async () => ({ resourceId: "remote-resource" }),
|
||||
saveSyncConfig: () => calls.push("config"),
|
||||
saveSyncBase: async () => {
|
||||
calls.push("base");
|
||||
throw new Error("base failed");
|
||||
},
|
||||
saveSyncAnchor: async () => calls.push("anchor"),
|
||||
saveProviderConnection: async () => calls.push("connection"),
|
||||
addSyncHistoryEntry: () => calls.push("history"),
|
||||
notifyStateChange: () => calls.push("notify"),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => commitRemoteInspectionImpl.call(manager, "github", remoteFile("github", 5, 500), payload("cloud")),
|
||||
/base failed/,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ["base"]);
|
||||
});
|
||||
|
||||
test("saveSyncBase reports storage failures so callers do not advance anchors", async () => {
|
||||
const originalWarn = console.warn;
|
||||
const manager = {
|
||||
state: {
|
||||
unlockedKey: {
|
||||
derivedKey: await crypto.subtle.generateKey(
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
true,
|
||||
["encrypt", "decrypt"],
|
||||
),
|
||||
},
|
||||
},
|
||||
syncBaseKey: () => "sync-base",
|
||||
saveToStorage: () => {
|
||||
throw new Error("storage full");
|
||||
},
|
||||
};
|
||||
|
||||
console.warn = () => {};
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => saveSyncBaseImpl.call(manager, payload("cloud"), "github"),
|
||||
/storage full/,
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
test("saveSyncBase reports a missing local encryption key", async () => {
|
||||
const manager = {
|
||||
state: { unlockedKey: null },
|
||||
syncBaseKey: () => "sync-base",
|
||||
saveToStorage: () => {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => saveSyncBaseImpl.call(manager, payload("cloud"), "github"),
|
||||
/Sync base encryption key is unavailable/,
|
||||
);
|
||||
});
|
||||
|
||||
test("saveSyncBase keeps a bounded encrypted snapshot history before replacing the base", async () => {
|
||||
const stored = new Map<string, string>();
|
||||
const key = await crypto.subtle.generateKey(
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
true,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
const manager = {
|
||||
state: { unlockedKey: { derivedKey: key } },
|
||||
syncBaseKey: (provider?: CloudProvider) => `base-${provider ?? "default"}`,
|
||||
syncSnapshotsKey: (provider?: CloudProvider) => `snapshots-${provider ?? "default"}`,
|
||||
saveToStorage: (storageKey: string, value: string) => stored.set(storageKey, value),
|
||||
loadFromStorage: (storageKey: string) => stored.get(storageKey),
|
||||
};
|
||||
|
||||
await saveSyncBaseImpl.call(manager, payload("base-0"), "github");
|
||||
for (let i = 1; i <= 7; i += 1) {
|
||||
await saveSyncBaseImpl.call(manager, payload(`base-${i}`), "github");
|
||||
}
|
||||
|
||||
const snapshots = await loadSyncSnapshotsImpl.call(manager, "github");
|
||||
|
||||
assert.equal(snapshots.length, 5);
|
||||
assert.deepEqual(
|
||||
snapshots.map((snapshot) => snapshot.payload.hosts[0]?.id),
|
||||
["base-6", "base-5", "base-4", "base-3", "base-2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("syncAllProviders builds provider-specific sync metadata from each provider base", async () => {
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
const uploaded: Array<{ provider: CloudProvider; payload: SyncPayload }> = [];
|
||||
const baseByProvider = {
|
||||
github: payload("shared"),
|
||||
google: payload("deleted-on-local"),
|
||||
} as Partial<Record<CloudProvider, SyncPayload>>;
|
||||
const localPayload = payload("shared");
|
||||
|
||||
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
|
||||
...remoteFile("github", 4, 400),
|
||||
payload: JSON.stringify(outgoing),
|
||||
});
|
||||
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "pw",
|
||||
adapters: new Map(),
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "connected" },
|
||||
google: { enabled: true, connected: true, status: "connected" },
|
||||
onedrive: { enabled: false, connected: false, status: "disconnected" },
|
||||
webdav: { enabled: false, connected: false, status: "disconnected" },
|
||||
s3: { enabled: false, connected: false, status: "disconnected" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "smartMerge",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getConnectedAdapter: async (provider: CloudProvider) => ({ provider }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async () => ({ conflict: false, remoteFile: null }),
|
||||
loadSyncBase: async (provider: CloudProvider) => baseByProvider[provider] ?? null,
|
||||
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
|
||||
uploaded.push({ provider, payload: outgoing });
|
||||
return { success: true, provider, action: "upload" as const, version: 4 };
|
||||
},
|
||||
exitBlockedState: () => {},
|
||||
notifyStateChange: () => {},
|
||||
};
|
||||
|
||||
await syncAllProvidersImpl.call(manager, localPayload);
|
||||
|
||||
assert.equal(uploaded.length, 2);
|
||||
assert.deepEqual(uploaded.find((entry) => entry.provider === "github")?.payload.syncMeta?.deletions, []);
|
||||
assert.deepEqual(uploaded.find((entry) => entry.provider === "google")?.payload.syncMeta?.deletions, [{
|
||||
entityType: "hosts",
|
||||
id: "deleted-on-local",
|
||||
deletedAt: uploaded.find((entry) => entry.provider === "google")?.payload.syncMeta?.generatedAt,
|
||||
deviceId: "local-device",
|
||||
}]);
|
||||
} finally {
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
|
||||
test("syncAllProviders upload-local override overwrites remote without decrypting when password differs", async () => {
|
||||
const originalDecryptPayload = EncryptionService.decryptPayload;
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
const checkedRemote = remoteFile("github", 5, 500);
|
||||
const localPayload = payload("local-after-reinstall");
|
||||
const uploaded: Array<{ provider: CloudProvider; payload: SyncPayload }> = [];
|
||||
const encryptBaseVersions: number[] = [];
|
||||
let decryptCalls = 0;
|
||||
|
||||
EncryptionService.decryptPayload = async () => {
|
||||
decryptCalls += 1;
|
||||
throw new Error("OperationError: unable to authenticate data");
|
||||
};
|
||||
EncryptionService.encryptPayload = async (
|
||||
outgoing: SyncPayload,
|
||||
_password: string,
|
||||
_deviceId: string,
|
||||
_deviceName: string,
|
||||
_appVersion: string,
|
||||
existingVersion?: number,
|
||||
) => {
|
||||
encryptBaseVersions.push(existingVersion ?? 0);
|
||||
return {
|
||||
...remoteFile("github", (existingVersion ?? 0) + 1, 600),
|
||||
payload: JSON.stringify(outgoing),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "new-master-password",
|
||||
adapters: new Map(),
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "connected" },
|
||||
google: { enabled: false, connected: false, status: "disconnected" },
|
||||
onedrive: { enabled: false, connected: false, status: "disconnected" },
|
||||
webdav: { enabled: false, connected: false, status: "disconnected" },
|
||||
s3: { enabled: false, connected: false, status: "disconnected" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "smartMerge",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getConnectedAdapter: async (provider: CloudProvider) => ({ provider }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async () => ({ conflict: true, remoteFile: checkedRemote }),
|
||||
loadSyncBase: async () => null,
|
||||
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
|
||||
uploaded.push({ provider, payload: outgoing });
|
||||
return { success: true, provider, action: "upload" as const, version: 6 };
|
||||
},
|
||||
exitBlockedState: () => {},
|
||||
notifyStateChange: () => {},
|
||||
};
|
||||
|
||||
const conflicted = await syncAllProvidersImpl.call(manager, localPayload);
|
||||
assert.equal(conflicted.get("github")?.success, false);
|
||||
assert.equal(conflicted.get("github")?.conflictDetected, true);
|
||||
assert.equal(conflicted.get("github")?.error, undefined);
|
||||
assert.equal(uploaded.length, 0);
|
||||
|
||||
const forced = await syncAllProvidersImpl.call(manager, localPayload, {
|
||||
conflictActionOverride: "upload-local",
|
||||
overrideShrink: true,
|
||||
});
|
||||
assert.equal(forced.get("github")?.success, true);
|
||||
assert.equal(forced.get("github")?.action, "upload");
|
||||
assert.equal(uploaded.length, 1);
|
||||
assert.equal(uploaded[0]?.payload.hosts[0]?.id, "local-after-reinstall");
|
||||
// Keep-local under smartMerge must base on the conflicting remote version
|
||||
// (v5 → encrypted as v6), matching single-provider upload-local.
|
||||
assert.deepEqual(encryptBaseVersions, [5]);
|
||||
// Shrink-guard may attempt decrypt and ignore failure; merge path must not run.
|
||||
assert.ok(decryptCalls >= 1);
|
||||
} finally {
|
||||
EncryptionService.decryptPayload = originalDecryptPayload;
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
|
||||
test("an initialized but paused v2 replica cannot fall through to legacy provider writes", async () => {
|
||||
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
const values = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
},
|
||||
});
|
||||
try {
|
||||
setConvergentSyncLocalConfig({ enabled: false, initialized: true });
|
||||
let adapterRequested = false;
|
||||
const manager = {
|
||||
state: {
|
||||
providers: {
|
||||
github: { provider: "github", status: "connected" },
|
||||
},
|
||||
},
|
||||
getConnectedAdapter: async () => {
|
||||
adapterRequested = true;
|
||||
throw new Error("legacy path must not run");
|
||||
},
|
||||
};
|
||||
|
||||
const all = await syncAllProvidersImpl.call(manager, payload("local"));
|
||||
const one = await syncToProviderImpl.call(manager, "github", payload("local"));
|
||||
|
||||
assert.equal(all.get("github")?.success, false);
|
||||
assert.match(all.get("github")?.error ?? "", /paused/i);
|
||||
assert.equal(one.success, false);
|
||||
assert.match(one.error ?? "", /paused/i);
|
||||
assert.equal(adapterRequested, false);
|
||||
} finally {
|
||||
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
}
|
||||
});
|
||||
|
||||
test("syncToProvider preserves a merged payload discovered by a non-target provider", () => {
|
||||
const mergedPayload = payload("remote-merged");
|
||||
const results = new Map<CloudProvider, SyncResult>([
|
||||
["github", {
|
||||
success: false,
|
||||
provider: "github",
|
||||
action: "none",
|
||||
error: "github unavailable",
|
||||
}],
|
||||
["google", {
|
||||
success: true,
|
||||
provider: "google",
|
||||
action: "merge",
|
||||
mergedPayload,
|
||||
}],
|
||||
]);
|
||||
|
||||
const selected = selectConvergentSyncToProviderResult("github", results);
|
||||
|
||||
assert.equal(selected.success, false);
|
||||
assert.equal(selected.provider, "github");
|
||||
assert.equal(selected.error, "github unavailable");
|
||||
assert.equal(selected.mergedPayload, mergedPayload);
|
||||
assert.equal(selected.remoteFile, undefined);
|
||||
});
|
||||
|
||||
test("syncAllProviders smart-merge strips device-bound enc:v1 secrets before upload", async () => {
|
||||
const originalDecryptPayload = EncryptionService.decryptPayload;
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
const completeBlob = Buffer.alloc(31, 0);
|
||||
Buffer.from("v10", "utf8").copy(completeBlob, 0);
|
||||
const ENC = `enc:v1:${completeBlob.toString("base64")}`;
|
||||
const checkedRemote = remoteFile("github", 5, 500);
|
||||
const localPayload = {
|
||||
...payload("shared"),
|
||||
hosts: [{
|
||||
...payload("shared").hosts[0]!,
|
||||
password: "kept-secret",
|
||||
}],
|
||||
};
|
||||
const remotePoisoned: SyncPayload = {
|
||||
...payload("shared"),
|
||||
hosts: [
|
||||
{
|
||||
...payload("shared").hosts[0]!,
|
||||
label: "remote-label",
|
||||
password: ENC,
|
||||
},
|
||||
],
|
||||
};
|
||||
const uploaded: SyncPayload[] = [];
|
||||
|
||||
EncryptionService.decryptPayload = async () => remotePoisoned;
|
||||
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
|
||||
...remoteFile("github", 6, 600),
|
||||
payload: JSON.stringify(outgoing),
|
||||
});
|
||||
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "pw",
|
||||
adapters: new Map(),
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "connected" },
|
||||
google: { enabled: false, connected: false, status: "disconnected" },
|
||||
onedrive: { enabled: false, connected: false, status: "disconnected" },
|
||||
webdav: { enabled: false, connected: false, status: "disconnected" },
|
||||
s3: { enabled: false, connected: false, status: "disconnected" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "smartMerge",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getConnectedAdapter: async (provider: CloudProvider) => ({ provider }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async () => ({ conflict: true, remoteFile: checkedRemote }),
|
||||
loadSyncBase: async () => ({
|
||||
...payload("shared"),
|
||||
hosts: [{
|
||||
...payload("shared").hosts[0]!,
|
||||
password: "kept-secret",
|
||||
}],
|
||||
}),
|
||||
uploadToProvider: async (
|
||||
_provider: CloudProvider,
|
||||
_adapter: unknown,
|
||||
_file: SyncedFile,
|
||||
outgoing: SyncPayload,
|
||||
) => {
|
||||
uploaded.push(outgoing);
|
||||
return { success: true, provider: "github" as const, action: "upload" as const, version: 6 };
|
||||
},
|
||||
exitBlockedState: () => {},
|
||||
notifyStateChange: () => {},
|
||||
};
|
||||
|
||||
const results = await syncAllProvidersImpl.call(manager, localPayload);
|
||||
assert.equal(results.get("github")?.success, true);
|
||||
assert.equal(uploaded.length, 1);
|
||||
const sharedHost = uploaded[0]?.hosts.find((host) => host.id === "shared");
|
||||
assert.ok(sharedHost);
|
||||
// Local/base usable secret must survive; remote non-secret edits can still apply.
|
||||
assert.equal(sharedHost?.password, "kept-secret");
|
||||
assert.equal(sharedHost?.label, "remote-label");
|
||||
} finally {
|
||||
EncryptionService.decryptPayload = originalDecryptPayload;
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
976
infrastructure/services/cloudSync/syncAllStorageMethods.ts
Normal file
976
infrastructure/services/cloudSync/syncAllStorageMethods.ts
Normal file
@@ -0,0 +1,976 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
SYNC_CONSTANTS,
|
||||
SYNC_STORAGE_KEYS,
|
||||
isProviderReadyForSync,
|
||||
} from '../../../domain/sync';
|
||||
import packageJson from '../../../package.json';
|
||||
import { EncryptionService } from '../EncryptionService';
|
||||
import { mergeSyncPayloads } from '../../../domain/syncMerge';
|
||||
import { stripSyncPayloadEncryptedCredentials, healPoisonedSecretsForMerge } from '../../../domain/credentials';
|
||||
import {
|
||||
SYNC_SNAPSHOT_LIMIT,
|
||||
summarizeSyncChanges,
|
||||
withSyncReliabilityMeta,
|
||||
} from '../../../domain/syncReliability';
|
||||
import { detectSuspiciousShrink, type ShrinkFinding } from '../../../domain/syncGuards';
|
||||
import { resolveCloudSyncConflictAction, type CloudSyncConflictAction, type CloudSyncStrategy } from '../../../domain/syncStrategy';
|
||||
import { assertConvergentSyncWriteCompatible } from '../../../domain/convergentSync';
|
||||
import { getConvergentSyncLocalConfig } from '../convergentSyncConfig';
|
||||
import { syncAllProvidersConvergentlyImpl } from './convergentSyncRuntimeMethods';
|
||||
import {
|
||||
coalesceStoredSyncPreferences,
|
||||
hasSyncPreferenceFields,
|
||||
resolveSyncPreferencesForPersist,
|
||||
resolveSyncVersionsForPersist,
|
||||
} from './syncConfigPersist';
|
||||
import type { CloudAdapter } from '../adapters';
|
||||
import type {
|
||||
CloudProvider,
|
||||
ProviderConnection,
|
||||
SyncedFile,
|
||||
SyncHistoryEntry,
|
||||
SyncSnapshotEntry,
|
||||
SyncPayload,
|
||||
SyncResult,
|
||||
} from '../../../domain/sync';
|
||||
// CloudProvider used when clearing dynamic plugin-provider bases/anchors.
|
||||
import {
|
||||
decryptLocalStorageValue,
|
||||
encryptLocalStorageValue,
|
||||
} from './encryptedLocalStorage';
|
||||
|
||||
function getSyncSecurityGeneration(manager: any): number | undefined {
|
||||
return typeof manager.getSyncSecurityGeneration === 'function'
|
||||
? manager.getSyncSecurityGeneration()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function assertSyncSecurityGeneration(manager: any, generation?: number): void {
|
||||
if (typeof manager.assertSyncSecurityGeneration === 'function') {
|
||||
manager.assertSyncSecurityGeneration(generation);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadRemoteForSyncAllImpl(this: any,
|
||||
provider: CloudProvider,
|
||||
remoteFile: SyncedFile,
|
||||
syncSecurityGeneration?: number,
|
||||
): Promise<SyncResult> {
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
const payload = stripSyncPayloadEncryptedCredentials(
|
||||
await EncryptionService.decryptPayload(remoteFile, this.masterPassword),
|
||||
);
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
this.updateProviderStatus(provider, 'connected');
|
||||
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
provider,
|
||||
action: 'download',
|
||||
version: remoteFile.meta.version,
|
||||
mergedPayload: payload,
|
||||
remoteFile,
|
||||
};
|
||||
this.emit({ type: 'SYNC_COMPLETED', provider, result });
|
||||
return result;
|
||||
}
|
||||
|
||||
const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
|
||||
const SYNC_SNAPSHOTS_STORAGE_KEY = 'netcatty_sync_snapshots_v1';
|
||||
|
||||
async function loadRawSyncBase(this: any, provider?: CloudProvider): Promise<SyncPayload | null> {
|
||||
const key = this.state.unlockedKey?.derivedKey;
|
||||
if (!key || typeof this.loadFromStorage !== 'function') return null;
|
||||
const encoded = this.loadFromStorage(this.syncBaseKey(provider));
|
||||
if (!encoded || typeof encoded !== 'string') return null;
|
||||
return decryptLocalStorageValue<SyncPayload>(encoded, key);
|
||||
}
|
||||
|
||||
async function rememberCurrentSyncBaseSnapshot(this: any, provider?: CloudProvider): Promise<void> {
|
||||
if (typeof this.syncSnapshotsKey !== 'function') return;
|
||||
const previous = await loadRawSyncBase.call(this, provider);
|
||||
if (!previous) return;
|
||||
const snapshots = await loadSyncSnapshotsImpl.call(this, provider);
|
||||
const entry: SyncSnapshotEntry = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
timestamp: Date.now(),
|
||||
...(provider ? { provider } : {}),
|
||||
payload: previous,
|
||||
};
|
||||
await saveSyncSnapshotsImpl.call(this, [entry, ...snapshots].slice(0, SYNC_SNAPSHOT_LIMIT), provider);
|
||||
}
|
||||
|
||||
export async function syncAllProvidersImpl(this: any,
|
||||
inputPayload?: SyncPayload,
|
||||
opts: {
|
||||
overrideShrink?: boolean;
|
||||
conflictActionOverride?: CloudSyncConflictAction;
|
||||
applyConvergentPayload?: (
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>;
|
||||
} = {},
|
||||
): Promise<Map<CloudProvider, SyncResult>> {
|
||||
const results = new Map<CloudProvider, SyncResult>();
|
||||
let payload = inputPayload;
|
||||
let wasMerged = false;
|
||||
|
||||
const convergentConfig = getConvergentSyncLocalConfig();
|
||||
if (convergentConfig.initialized && inputPayload) {
|
||||
if (convergentConfig.enabled) {
|
||||
return syncAllProvidersConvergentlyImpl.call(this, inputPayload, {
|
||||
...opts,
|
||||
applyPayload: opts.applyConvergentPayload,
|
||||
});
|
||||
}
|
||||
const message = 'Convergent sync is paused on this device';
|
||||
for (const [provider, connection] of Object.entries(
|
||||
this.state.providers as Record<CloudProvider, ProviderConnection>,
|
||||
)) {
|
||||
if (!isProviderReadyForSync(connection)) continue;
|
||||
results.set(provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: provider as CloudProvider,
|
||||
action: 'none',
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const overrideShrinkRequested = opts.overrideShrink === true;
|
||||
const syncSecurityGeneration = getSyncSecurityGeneration(this);
|
||||
|
||||
if (!payload) {
|
||||
// Caller should provide payload from app state
|
||||
return results;
|
||||
}
|
||||
|
||||
if (this.state.securityState !== 'UNLOCKED') {
|
||||
return results; // Or throw? Caller handles it.
|
||||
}
|
||||
|
||||
if (!this.masterPassword) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const connectedProviders = Object.entries(
|
||||
this.state.providers as Record<CloudProvider, ProviderConnection>,
|
||||
)
|
||||
.filter(([provider, connection]) => {
|
||||
if (!isProviderReadyForSync(connection)) return false;
|
||||
if (connection.status === 'error') {
|
||||
this.state.providers[provider as CloudProvider].status = 'connected';
|
||||
this.state.providers[provider as CloudProvider].error = undefined;
|
||||
// Clear cached adapter so a fresh one is created with current (decrypted) tokens
|
||||
this.adapters.delete(provider as CloudProvider);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(([p]) => p as CloudProvider);
|
||||
|
||||
if (connectedProviders.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
this.state.lastError = null;
|
||||
this.state.syncState = 'SYNCING';
|
||||
|
||||
// 1. Parallel Checks
|
||||
const checkTasks = connectedProviders.map(async (provider) => {
|
||||
try {
|
||||
// We handle connection error here to prevent one provider blocking others
|
||||
const adapter = await this.getConnectedAdapter(provider);
|
||||
this.updateProviderStatus(provider, 'syncing');
|
||||
this.emit({ type: 'SYNC_STARTED', provider });
|
||||
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
const check = await this.checkProviderConflict(provider, adapter);
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
return { provider, adapter, check };
|
||||
} catch (error) {
|
||||
return { provider, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
const checkResults = await Promise.all(checkTasks);
|
||||
|
||||
// 2. Analyze Results & Handle Conflicts — merge ALL conflicting providers
|
||||
//
|
||||
// Contract: every connected provider is assumed to mirror the *same*
|
||||
// logical vault. When providers hold divergent content (e.g. user
|
||||
// intentionally points GitHub and OneDrive at separate accounts with
|
||||
// different data), uploading the conflict-merged payload below will
|
||||
// overwrite provider-unique content on non-conflicting providers. A
|
||||
// proper fix requires per-provider compare-and-swap (follow-up work,
|
||||
// see I-1 and `docs/`). Until then, we log a diagnostic warning when
|
||||
// we detect cross-provider base divergence so the issue is visible in
|
||||
// support logs.
|
||||
const conflicts = checkResults.filter((r) => !r.error && r.check?.conflict && r.check?.remoteFile);
|
||||
|
||||
// Instrumentation only — detect divergent provider bases (an
|
||||
// unsupported configuration). Cheap: bases are already persisted
|
||||
// and we only read their aggregate counts.
|
||||
if (checkResults.filter((r) => !r.error).length > 1) {
|
||||
try {
|
||||
const summaries = await Promise.all(
|
||||
checkResults
|
||||
.filter((r) => !r.error)
|
||||
.map(async (r) => {
|
||||
const base = await this.loadSyncBase(r.provider as CloudProvider);
|
||||
return {
|
||||
provider: r.provider,
|
||||
hosts: base?.hosts?.length ?? 0,
|
||||
keys: base?.keys?.length ?? 0,
|
||||
snippets: base?.snippets?.length ?? 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const signatures = summaries.map((s) => `${s.hosts}/${s.keys}/${s.snippets}`);
|
||||
const allSame = signatures.every((sig) => sig === signatures[0]);
|
||||
if (!allSame) {
|
||||
console.warn(
|
||||
'[CloudSyncManager] syncAll: connected providers hold divergent bases (multi-account setup?). Uploading the conflict-merged payload will replace each provider\'s current remote. See I-7 in PR #720 for context.',
|
||||
summaries,
|
||||
);
|
||||
// Surface the same finding to the UI so multi-account / intentionally
|
||||
// diverged configurations can be warned visibly instead of silently
|
||||
// having one provider's data merged over another's (#779 follow-up).
|
||||
this.emit({
|
||||
type: 'PROVIDERS_DIVERGED',
|
||||
summaries: summaries.map((s) => ({
|
||||
provider: s.provider as CloudProvider,
|
||||
hosts: s.hosts,
|
||||
keys: s.keys,
|
||||
snippets: s.snippets,
|
||||
})),
|
||||
});
|
||||
}
|
||||
} catch (diagError) {
|
||||
// Non-fatal diagnostic; never let it block the sync.
|
||||
console.warn('[CloudSyncManager] syncAll: base-divergence check failed:', diagError);
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
const conflictAction = opts.conflictActionOverride
|
||||
?? resolveCloudSyncConflictAction(this.state.syncStrategy, {
|
||||
hasConflict: true,
|
||||
hasRemoteFile: true,
|
||||
});
|
||||
|
||||
if (conflictAction === 'download-remote') {
|
||||
const newestConflict = conflicts.reduce((latest, entry) => {
|
||||
const latestUpdatedAt = latest.check?.remoteFile?.meta.updatedAt ?? 0;
|
||||
const entryUpdatedAt = entry.check?.remoteFile?.meta.updatedAt ?? 0;
|
||||
return entryUpdatedAt > latestUpdatedAt ? entry : latest;
|
||||
});
|
||||
try {
|
||||
const remoteResult = await downloadRemoteForSyncAllImpl.call(
|
||||
this,
|
||||
newestConflict.provider as CloudProvider,
|
||||
newestConflict.check!.remoteFile!,
|
||||
syncSecurityGeneration,
|
||||
);
|
||||
const sourceProvider = newestConflict.provider as CloudProvider;
|
||||
results.set(sourceProvider, remoteResult);
|
||||
payload = remoteResult.mergedPayload ?? payload;
|
||||
|
||||
for (const r of checkResults) {
|
||||
const provider = r.provider as CloudProvider;
|
||||
if (provider === sourceProvider) continue;
|
||||
if (r.check) {
|
||||
r.check.conflict = false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = String(error);
|
||||
this.state.syncState = 'ERROR';
|
||||
this.state.lastError = msg;
|
||||
this.updateProviderStatus(newestConflict.provider as CloudProvider, 'error', msg);
|
||||
this.emit({ type: 'SYNC_ERROR', provider: newestConflict.provider as CloudProvider, error: msg });
|
||||
results.set(newestConflict.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: newestConflict.provider as CloudProvider,
|
||||
action: 'none',
|
||||
error: msg,
|
||||
});
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
if (conflictAction === 'upload-local') {
|
||||
for (const r of checkResults) {
|
||||
if (r.check) r.check.conflict = false;
|
||||
}
|
||||
} else if (conflictAction === 'smart-merge') {
|
||||
// Three-way merge: incorporate remote data from every conflicting provider
|
||||
try {
|
||||
let merged = payload;
|
||||
for (const c of conflicts) {
|
||||
const providerBase = await this.loadSyncBase(c.provider as CloudProvider);
|
||||
const remoteRaw = await EncryptionService.decryptPayload(
|
||||
c.check!.remoteFile!,
|
||||
this.masterPassword,
|
||||
);
|
||||
const localHealed = healPoisonedSecretsForMerge(merged, remoteRaw, providerBase);
|
||||
const remotePayload = healPoisonedSecretsForMerge(
|
||||
remoteRaw,
|
||||
merged,
|
||||
providerBase,
|
||||
);
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
const result = mergeSyncPayloads(providerBase, localHealed, remotePayload);
|
||||
merged = result.payload;
|
||||
}
|
||||
const mergeResult = {
|
||||
payload: stripSyncPayloadEncryptedCredentials(merged),
|
||||
};
|
||||
|
||||
console.info('[CloudSyncManager] syncAll: three-way merge completed');
|
||||
|
||||
// Replace payload with merged payload for upload to all providers
|
||||
payload = mergeResult.payload;
|
||||
wasMerged = true;
|
||||
|
||||
// Re-classify: all providers (including the conflicting one) should now upload
|
||||
// Clear the conflict check result so all go through the upload path
|
||||
for (const r of checkResults) {
|
||||
if (r.check) r.check.conflict = false;
|
||||
}
|
||||
} catch (mergeError) {
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
// Merge failed — fall back to conflict UI
|
||||
console.error('[CloudSyncManager] syncAll: merge failed', mergeError);
|
||||
const { provider, check } = conflicts[0];
|
||||
const remoteFile = check!.remoteFile!;
|
||||
let conflictSummary;
|
||||
try {
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
const base = await this.loadSyncBase(provider as CloudProvider);
|
||||
const remotePayload = await EncryptionService.decryptPayload(remoteFile, this.masterPassword);
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
conflictSummary = summarizeSyncChanges(
|
||||
base,
|
||||
payload,
|
||||
remotePayload,
|
||||
);
|
||||
} catch {
|
||||
conflictSummary = undefined;
|
||||
}
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
|
||||
this.state.syncState = 'CONFLICT';
|
||||
this.state.currentConflict = {
|
||||
provider: provider as CloudProvider,
|
||||
localVersion: this.state.localVersion,
|
||||
localUpdatedAt: this.state.localUpdatedAt,
|
||||
localDeviceName: this.state.deviceName,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
remoteUpdatedAt: remoteFile.meta.updatedAt,
|
||||
remoteDeviceName: remoteFile.meta.deviceName,
|
||||
...(conflictSummary ? { changeSummary: conflictSummary } : {}),
|
||||
};
|
||||
|
||||
this.emit({
|
||||
type: 'CONFLICT_DETECTED',
|
||||
conflict: this.state.currentConflict,
|
||||
});
|
||||
|
||||
for (const r of checkResults) {
|
||||
if (r.error) {
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
error: r.error,
|
||||
});
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
|
||||
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
|
||||
} else if (r.provider === conflicts[0].provider) {
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
conflictDetected: true,
|
||||
});
|
||||
} else {
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'connected');
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: true,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shrink guard (multi-provider): check the final outgoing payload against
|
||||
// each provider's stored base. If ANY provider would suffer a suspicious
|
||||
// shrink, block ALL uploads — the same payload goes to every provider, so
|
||||
// any one provider's "would lose too much" is a global block. Override flag
|
||||
// is one-shot and clears regardless of outcome.
|
||||
const shrinkSuspectByProvider: Array<{
|
||||
provider: CloudProvider;
|
||||
finding: Extract<ShrinkFinding, { suspicious: true }>;
|
||||
}> = [];
|
||||
const candidateProviders = checkResults
|
||||
.filter((r) => !r.error && !r.check?.conflict && r.adapter)
|
||||
.map((r) => r.provider as CloudProvider);
|
||||
for (const provider of candidateProviders) {
|
||||
const providerBase = await this.loadSyncBase(provider);
|
||||
// When no stored base exists, fall back to the remote payload fetched
|
||||
// during the parallel check above — the shrink guard needs a reference
|
||||
// or it fails open and lets degraded local state overwrite remote
|
||||
// (#779). checkResults carries the per-provider remoteFile already.
|
||||
let providerRemoteRef: SyncPayload | null = null;
|
||||
if (!providerBase) {
|
||||
const entry = checkResults.find((r) => r.provider === provider);
|
||||
const remoteFile = entry?.check?.remoteFile;
|
||||
if (remoteFile) {
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
try {
|
||||
providerRemoteRef = await EncryptionService.decryptPayload(
|
||||
remoteFile,
|
||||
this.masterPassword,
|
||||
);
|
||||
} catch {
|
||||
providerRemoteRef = null;
|
||||
}
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
}
|
||||
}
|
||||
const finding = detectSuspiciousShrink(payload, providerBase, providerRemoteRef);
|
||||
if (finding.suspicious) {
|
||||
shrinkSuspectByProvider.push({ provider, finding });
|
||||
}
|
||||
}
|
||||
const shouldBlockAll = shrinkSuspectByProvider.length > 0 && !overrideShrinkRequested;
|
||||
const shouldForceAll = shrinkSuspectByProvider.length > 0 && overrideShrinkRequested;
|
||||
|
||||
if (shouldBlockAll) {
|
||||
this.state.syncState = 'BLOCKED';
|
||||
this.state.lastShrinkFinding = shrinkSuspectByProvider[0].finding;
|
||||
for (const { provider, finding } of shrinkSuspectByProvider) {
|
||||
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding });
|
||||
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
|
||||
results.set(provider, {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
shrinkBlocked: true,
|
||||
finding,
|
||||
});
|
||||
}
|
||||
// Process check errors from the parallel check phase so a provider that
|
||||
// failed during checkProviderConflict is not silently dropped from results.
|
||||
checkResults.forEach((r) => {
|
||||
if (r.error) {
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
error: r.error,
|
||||
});
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
|
||||
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
|
||||
}
|
||||
});
|
||||
// Providers in candidateProviders that didn't trip the shrink check still
|
||||
// share the same payload — mark them as not-uploaded so the caller doesn't
|
||||
// think a "successful" no-op happened.
|
||||
const blockedProviders = new Set(shrinkSuspectByProvider.map((e) => e.provider));
|
||||
for (const provider of candidateProviders) {
|
||||
if (!results.has(provider) && !blockedProviders.has(provider)) {
|
||||
results.set(provider, {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
error: 'Sync blocked: another provider would lose too much data',
|
||||
});
|
||||
this.updateProviderStatus(provider, 'error', 'Sync blocked due to peer provider');
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
if (shouldForceAll) {
|
||||
for (const { provider, finding } of shrinkSuspectByProvider) {
|
||||
this.emit({ type: 'SYNC_FORCED', provider, finding });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Encrypt Once
|
||||
const validUploads = checkResults.filter(
|
||||
(r) => !r.error && !r.check?.conflict && r.adapter
|
||||
) as { provider: CloudProvider; adapter: CloudAdapter }[];
|
||||
|
||||
if (validUploads.length === 0) {
|
||||
// Process errors if any
|
||||
checkResults.forEach((r) => {
|
||||
if (r.error) {
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
error: r.error,
|
||||
});
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
|
||||
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
|
||||
}
|
||||
});
|
||||
if (Array.from(results.values()).some((r) => r.success)) {
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
} else {
|
||||
this.state.syncState = 'ERROR';
|
||||
}
|
||||
this.notifyStateChange();
|
||||
return results;
|
||||
}
|
||||
|
||||
// Use the highest version as base: either local or any remote that was merged
|
||||
// or forcibly overwritten. Explicit keep-local (conflictActionOverride
|
||||
// upload-local) can run while syncStrategy is still smartMerge; without
|
||||
// taking the remote version here, encryptPayload would mint local+1 and
|
||||
// regress past a higher conflicting remote (e.g. local v1 over remote v5).
|
||||
let baseVersion = this.state.localVersion;
|
||||
if (
|
||||
wasMerged
|
||||
|| (
|
||||
conflicts.length > 0
|
||||
&& (
|
||||
this.state.syncStrategy !== 'smartMerge'
|
||||
|| opts.conflictActionOverride === 'upload-local'
|
||||
)
|
||||
)
|
||||
) {
|
||||
for (const c of conflicts) {
|
||||
const rv = c.check?.remoteFile?.meta?.version ?? 0;
|
||||
if (rv > baseVersion) baseVersion = rv;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Parallel Uploads — each provider gets metadata derived from its own
|
||||
// base, then that exact payload is persisted as the provider base
|
||||
// inside uploadToProvider BEFORE the per-provider anchor advances.
|
||||
// Ordering matters: a crash between the two writes must leave the
|
||||
// stale anchor re-triggering inspection on next startup, not a
|
||||
// fresh anchor paired with a stale base.
|
||||
const uploadTasks = validUploads.map(async ({ provider, adapter }) => {
|
||||
try {
|
||||
const entry = checkResults.find((result) => result.provider === provider);
|
||||
assertConvergentSyncWriteCompatible(entry?.check?.remoteFile?.meta, payload);
|
||||
const providerBase = await this.loadSyncBase(provider);
|
||||
let providerRemoteRef: SyncPayload | null = null;
|
||||
if (!providerBase) {
|
||||
const remoteFile = entry?.check?.remoteFile;
|
||||
if (remoteFile) {
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
try {
|
||||
providerRemoteRef = await EncryptionService.decryptPayload(
|
||||
remoteFile,
|
||||
this.masterPassword,
|
||||
);
|
||||
} catch {
|
||||
providerRemoteRef = null;
|
||||
}
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
}
|
||||
}
|
||||
const providerPayload = withSyncReliabilityMeta(payload, providerBase ?? providerRemoteRef, {
|
||||
deviceId: this.state.deviceId,
|
||||
now: Date.now(),
|
||||
});
|
||||
const syncedFile = await EncryptionService.encryptPayload(
|
||||
providerPayload,
|
||||
this.masterPassword,
|
||||
this.state.deviceId,
|
||||
this.state.deviceName,
|
||||
packageJson.version,
|
||||
baseVersion
|
||||
);
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
const result = await this.uploadToProvider(provider, adapter, syncedFile, providerPayload, syncSecurityGeneration);
|
||||
results.set(provider, result);
|
||||
} catch (error) {
|
||||
const msg = String(error);
|
||||
this.state.lastError = msg;
|
||||
this.updateProviderStatus(provider, 'error', msg);
|
||||
this.emit({ type: 'SYNC_ERROR', provider, error: msg });
|
||||
results.set(provider, {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(uploadTasks);
|
||||
|
||||
// 5. Final State Update
|
||||
const resultList = Array.from(results.values());
|
||||
const hasSuccess = resultList.some((r) => r.success);
|
||||
const hasConflict = resultList.some((r) => r.conflictDetected);
|
||||
if (hasConflict) {
|
||||
// Prefer CONFLICT over IDLE even when another provider succeeded, so the
|
||||
// conflict UI from uploadToProvider is not wiped by a mixed multi-provider run.
|
||||
this.state.syncState = 'CONFLICT';
|
||||
if (wasMerged && payload) {
|
||||
for (const [p, r] of results) {
|
||||
if (r.success) {
|
||||
results.set(p, { ...r, action: 'merge', mergedPayload: payload });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (hasSuccess) {
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.state.lastShrinkFinding = undefined;
|
||||
|
||||
// If a merge happened, attach the merged payload to successful results
|
||||
// so callers can apply remote additions to local state
|
||||
if (wasMerged && payload) {
|
||||
for (const [p, r] of results) {
|
||||
if (r.success) {
|
||||
results.set(p, { ...r, action: 'merge', mergedPayload: payload });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.state.syncState = 'ERROR';
|
||||
// lastError is set by uploadToProvider
|
||||
}
|
||||
this.notifyStateChange(); // Notify UI that sync is complete
|
||||
|
||||
// Process errors from initial checks (if any)
|
||||
checkResults.forEach((r) => {
|
||||
if (r.error) {
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
error: r.error,
|
||||
});
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
|
||||
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function setDeviceNameImpl(this: any,name: string): void {
|
||||
this.state.deviceName = name;
|
||||
this.saveToStorage(SYNC_STORAGE_KEYS.DEVICE_NAME, name);
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
export function setAutoSyncImpl(this: any,enabled: boolean, intervalMinutes?: number): void {
|
||||
this.state.autoSyncEnabled = enabled;
|
||||
const memoryKeys: Array<'autoSync' | 'interval'> = ['autoSync'];
|
||||
if (intervalMinutes) {
|
||||
this.state.autoSyncInterval = Math.max(
|
||||
SYNC_CONSTANTS.MIN_SYNC_INTERVAL,
|
||||
Math.min(SYNC_CONSTANTS.MAX_SYNC_INTERVAL, intervalMinutes)
|
||||
);
|
||||
memoryKeys.push('interval');
|
||||
}
|
||||
// Preference write: only the fields this setter owns — leave syncStrategy
|
||||
// (and interval when unchanged) to whatever is already persisted so another
|
||||
// window's concurrent edit is not overwritten by stale memory.
|
||||
this.saveSyncConfig({ preferencesFromMemory: true, memoryKeys });
|
||||
this.notifyStateChange(); // Notify UI of state change
|
||||
|
||||
if (enabled && this.state.securityState === 'UNLOCKED') {
|
||||
this.startAutoSync();
|
||||
} else {
|
||||
this.stopAutoSync();
|
||||
}
|
||||
}
|
||||
|
||||
export function startAutoSyncImpl(this: any): void {
|
||||
if (this.autoSyncTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.autoSyncTimer = setInterval(
|
||||
() => {
|
||||
// Auto-sync callback - caller should provide payload
|
||||
this.emit({ type: 'SYNC_STARTED', provider: 'github' }); // Trigger UI to initiate sync
|
||||
},
|
||||
this.state.autoSyncInterval * 60 * 1000
|
||||
);
|
||||
}
|
||||
|
||||
export function stopAutoSyncImpl(this: any): void {
|
||||
if (this.autoSyncTimer) {
|
||||
clearInterval(this.autoSyncTimer);
|
||||
this.autoSyncTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSyncConfigImpl(
|
||||
this: any,
|
||||
opts?: {
|
||||
preferencesFromMemory?: boolean;
|
||||
memoryKeys?: ReadonlyArray<'autoSync' | 'interval' | 'syncStrategy'>;
|
||||
},
|
||||
): void {
|
||||
const preferencesFromMemory = opts?.preferencesFromMemory === true;
|
||||
const memoryKeys = opts?.memoryKeys;
|
||||
type StoredPrefs = {
|
||||
autoSync?: boolean;
|
||||
interval?: number;
|
||||
syncStrategy?: unknown;
|
||||
};
|
||||
type StoredConfig = StoredPrefs & {
|
||||
localVersion?: number;
|
||||
localUpdatedAt?: number;
|
||||
remoteVersion?: number;
|
||||
remoteUpdatedAt?: number;
|
||||
};
|
||||
|
||||
const adoptPreferences = (nextPrefs: {
|
||||
autoSync: boolean;
|
||||
interval: number;
|
||||
syncStrategy: CloudSyncStrategy;
|
||||
}): boolean => {
|
||||
const autoSyncChanged = this.state.autoSyncEnabled !== nextPrefs.autoSync;
|
||||
const intervalChanged = this.state.autoSyncInterval !== nextPrefs.interval;
|
||||
const strategyChanged = this.state.syncStrategy !== nextPrefs.syncStrategy;
|
||||
this.state.autoSyncEnabled = nextPrefs.autoSync;
|
||||
this.state.autoSyncInterval = nextPrefs.interval;
|
||||
this.state.syncStrategy = nextPrefs.syncStrategy;
|
||||
if (autoSyncChanged) {
|
||||
if (nextPrefs.autoSync && this.state.securityState === 'UNLOCKED') {
|
||||
this.startAutoSync?.();
|
||||
} else {
|
||||
this.stopAutoSync?.();
|
||||
}
|
||||
}
|
||||
return autoSyncChanged || intervalChanged || strategyChanged;
|
||||
};
|
||||
|
||||
const memoryPreferences = {
|
||||
autoSync: this.state.autoSyncEnabled,
|
||||
interval: this.state.autoSyncInterval,
|
||||
syncStrategy: this.state.syncStrategy,
|
||||
};
|
||||
|
||||
// Preference writers only touch SYNC_PREFERENCES so a concurrent
|
||||
// version bump cannot re-enable auto-sync via a shared RMW blob (#2976).
|
||||
// When memoryKeys is set, merge owned fields onto the stored snapshot so
|
||||
// a strategy-only write cannot revive a stale autoSync from memory.
|
||||
if (preferencesFromMemory) {
|
||||
const storedPreferences = this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as
|
||||
| StoredPrefs
|
||||
| null
|
||||
| undefined;
|
||||
const storedConfig = this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_CONFIG) as
|
||||
| StoredConfig
|
||||
| null
|
||||
| undefined;
|
||||
const nextPreferences = resolveSyncPreferencesForPersist({
|
||||
memory: memoryPreferences,
|
||||
stored: coalesceStoredSyncPreferences(storedPreferences, storedConfig),
|
||||
preferencesFromMemory: true,
|
||||
memoryKeys,
|
||||
});
|
||||
this.saveToStorage(SYNC_STORAGE_KEYS.SYNC_PREFERENCES, nextPreferences);
|
||||
return;
|
||||
}
|
||||
|
||||
const storedPreferences = this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as
|
||||
| StoredPrefs
|
||||
| null
|
||||
| undefined;
|
||||
const storedConfig = this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_CONFIG) as
|
||||
| StoredConfig
|
||||
| null
|
||||
| undefined;
|
||||
const hasSeparatePreferences = Boolean(
|
||||
storedPreferences && typeof storedPreferences === 'object',
|
||||
);
|
||||
|
||||
const nextVersions = resolveSyncVersionsForPersist({
|
||||
localVersion: this.state.localVersion,
|
||||
localUpdatedAt: this.state.localUpdatedAt,
|
||||
remoteVersion: this.state.remoteVersion,
|
||||
remoteUpdatedAt: this.state.remoteUpdatedAt,
|
||||
});
|
||||
|
||||
// Version-only saves never write SYNC_PREFERENCES. The dedicated key is
|
||||
// created only by preference writers (setAutoSync / setSyncStrategy).
|
||||
// A check-then-write migrate here can overwrite a concurrent
|
||||
// autoSync=false from another window (#2976).
|
||||
if (hasSeparatePreferences || !hasSyncPreferenceFields(storedConfig)) {
|
||||
this.saveToStorage(SYNC_STORAGE_KEYS.SYNC_CONFIG, nextVersions);
|
||||
} else {
|
||||
// Keep legacy preference fields in SYNC_CONFIG until a preference
|
||||
// writer splits them out. Take those fields from storage, never
|
||||
// from this window's possibly stale memory.
|
||||
const preservedPreferences = resolveSyncPreferencesForPersist({
|
||||
memory: memoryPreferences,
|
||||
stored: coalesceStoredSyncPreferences(null, storedConfig),
|
||||
preferencesFromMemory: false,
|
||||
});
|
||||
this.saveToStorage(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
|
||||
...preservedPreferences,
|
||||
...nextVersions,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-read preferences after the version write so a toggle that landed
|
||||
// during the version persist window is adopted into this process.
|
||||
const latestPreferences = resolveSyncPreferencesForPersist({
|
||||
memory: memoryPreferences,
|
||||
stored: coalesceStoredSyncPreferences(
|
||||
this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as StoredPrefs | null | undefined,
|
||||
this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_CONFIG) as StoredConfig | null | undefined,
|
||||
),
|
||||
preferencesFromMemory: false,
|
||||
});
|
||||
const shouldNotifyPreferenceAdopt = adoptPreferences(latestPreferences);
|
||||
if (shouldNotifyPreferenceAdopt) {
|
||||
this.notifyStateChange?.();
|
||||
}
|
||||
}
|
||||
|
||||
export function syncBaseKeyImpl(this: any,provider?: CloudProvider): string {
|
||||
const suffix = provider ? `_${provider}` : '';
|
||||
return `${SYNC_STORAGE_KEYS.SYNC_BASE_PAYLOAD}${suffix}`;
|
||||
}
|
||||
|
||||
export function providerAccountIdKeyImpl(this: any,provider: CloudProvider): string {
|
||||
return `netcatty.sync.accountId.${provider}`;
|
||||
}
|
||||
|
||||
export function loadProviderAccountIdImpl(this: any,provider: CloudProvider): string | null {
|
||||
const stored = this.loadFromStorage(this.providerAccountIdKey(provider));
|
||||
return typeof stored === 'string' ? stored : null;
|
||||
}
|
||||
|
||||
export function saveProviderAccountIdImpl(this: any,provider: CloudProvider, id: string): void {
|
||||
this.saveToStorage(this.providerAccountIdKey(provider), id);
|
||||
}
|
||||
|
||||
export async function saveSyncBaseImpl(this: any,payload: SyncPayload, provider?: CloudProvider): Promise<void> {
|
||||
const key = this.state.unlockedKey?.derivedKey;
|
||||
if (!key) {
|
||||
throw new Error('Sync base encryption key is unavailable');
|
||||
}
|
||||
try {
|
||||
try {
|
||||
await rememberCurrentSyncBaseSnapshot.call(this, provider);
|
||||
} catch (snapshotError) {
|
||||
console.warn('[CloudSyncManager] Failed to save previous sync snapshot', snapshotError);
|
||||
}
|
||||
if (
|
||||
this.saveToStorage(
|
||||
this.syncBaseKey(provider),
|
||||
await encryptLocalStorageValue(payload, key),
|
||||
) === false
|
||||
) {
|
||||
throw new Error('Unable to persist sync base');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[CloudSyncManager] Failed to save sync base', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSyncBaseImpl(this: any,provider?: CloudProvider): Promise<SyncPayload | null> {
|
||||
const key = this.state.unlockedKey?.derivedKey;
|
||||
if (!key) return null;
|
||||
try {
|
||||
const encoded = this.loadFromStorage(this.syncBaseKey(provider)) as unknown;
|
||||
if (!encoded || typeof encoded !== 'string') return null;
|
||||
return decryptLocalStorageValue<SyncPayload>(encoded, key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function syncSnapshotsKeyImpl(this: any,provider?: CloudProvider): string {
|
||||
const suffix = provider ? `_${provider}` : '';
|
||||
return `${SYNC_SNAPSHOTS_STORAGE_KEY}${suffix}`;
|
||||
}
|
||||
|
||||
export async function loadSyncSnapshotsImpl(this: any,provider?: CloudProvider): Promise<SyncSnapshotEntry[]> {
|
||||
const key = this.state.unlockedKey?.derivedKey;
|
||||
if (!key) return [];
|
||||
try {
|
||||
const encoded = this.loadFromStorage(this.syncSnapshotsKey(provider)) as unknown;
|
||||
if (!encoded || typeof encoded !== 'string') return [];
|
||||
const snapshots = await decryptLocalStorageValue<SyncSnapshotEntry[]>(encoded, key);
|
||||
return Array.isArray(snapshots) ? snapshots : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSyncSnapshotsImpl(this: any,snapshots: SyncSnapshotEntry[], provider?: CloudProvider): Promise<void> {
|
||||
const key = this.state.unlockedKey?.derivedKey;
|
||||
if (!key) {
|
||||
throw new Error('Sync snapshot encryption key is unavailable');
|
||||
}
|
||||
if (this.saveToStorage(
|
||||
this.syncSnapshotsKey(provider),
|
||||
await encryptLocalStorageValue(snapshots.slice(0, SYNC_SNAPSHOT_LIMIT), key),
|
||||
) === false) throw new Error('Unable to persist sync snapshots');
|
||||
}
|
||||
|
||||
export function clearSyncBaseImpl(this: any): void {
|
||||
this.removeFromStorage(SYNC_STORAGE_KEYS.SYNC_BASE_PAYLOAD);
|
||||
if (typeof this.syncSnapshotsKey === 'function') {
|
||||
this.removeFromStorage(this.syncSnapshotsKey());
|
||||
}
|
||||
const providers = new Set<CloudProvider>([
|
||||
'github', 'google', 'onedrive', 'webdav', 's3',
|
||||
]);
|
||||
for (const id of Object.keys(this.state?.providers ?? {})) {
|
||||
providers.add(id as CloudProvider);
|
||||
}
|
||||
if (typeof this.listRegisteredPluginProviderIds === 'function') {
|
||||
for (const id of this.listRegisteredPluginProviderIds()) {
|
||||
providers.add(id as CloudProvider);
|
||||
}
|
||||
}
|
||||
for (const p of providers) {
|
||||
this.removeFromStorage(this.syncBaseKey(p));
|
||||
this.removeFromStorage(this.convergentProviderBaselineKey(p));
|
||||
if (typeof this.syncSnapshotsKey === 'function') {
|
||||
this.removeFromStorage(this.syncSnapshotsKey(p));
|
||||
}
|
||||
}
|
||||
this.clearSyncAnchor();
|
||||
}
|
||||
|
||||
export function addSyncHistoryEntryImpl(this: any,entry: Omit<SyncHistoryEntry, 'id'>): void {
|
||||
const newEntry: SyncHistoryEntry = {
|
||||
...entry,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
|
||||
// Keep only the last 50 entries
|
||||
this.state.syncHistory = [newEntry, ...this.state.syncHistory].slice(0, 50);
|
||||
this.saveToStorage(SYNC_HISTORY_STORAGE_KEY, this.state.syncHistory);
|
||||
this.notifyStateChange(); // Notify UI of new history entry
|
||||
}
|
||||
|
||||
export function resetLocalVersionImpl(this: any): void {
|
||||
this.state.localVersion = 0;
|
||||
this.state.localUpdatedAt = 0;
|
||||
this.state.syncHistory = [];
|
||||
this.saveSyncConfig();
|
||||
this.saveToStorage(SYNC_HISTORY_STORAGE_KEY, []);
|
||||
this.clearSyncBase();
|
||||
this.clearSyncAnchor();
|
||||
this.notifyStateChange();
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { SYNC_STORAGE_KEYS } from '../../../domain/sync.ts';
|
||||
import { saveSyncConfigImpl, setAutoSyncImpl } from './syncAllStorageMethods.ts';
|
||||
|
||||
test('version saveSyncConfig does not clobber another window autoSync=false', () => {
|
||||
const storage = new Map<string, unknown>();
|
||||
storage.set(SYNC_STORAGE_KEYS.SYNC_PREFERENCES, {
|
||||
autoSync: false,
|
||||
interval: 5,
|
||||
syncStrategy: 'smartMerge',
|
||||
});
|
||||
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
|
||||
localVersion: 3,
|
||||
localUpdatedAt: 100,
|
||||
remoteVersion: 3,
|
||||
remoteUpdatedAt: 100,
|
||||
});
|
||||
|
||||
let stopCount = 0;
|
||||
const manager = {
|
||||
state: {
|
||||
autoSyncEnabled: true, // stale — this window has not observed the other window yet
|
||||
autoSyncInterval: 5,
|
||||
localVersion: 4,
|
||||
localUpdatedAt: 200,
|
||||
remoteVersion: 4,
|
||||
remoteUpdatedAt: 200,
|
||||
syncStrategy: 'smartMerge',
|
||||
securityState: 'UNLOCKED',
|
||||
},
|
||||
loadFromStorage(key: string) {
|
||||
return storage.get(key) ?? null;
|
||||
},
|
||||
saveToStorage(key: string, value: unknown) {
|
||||
storage.set(key, value);
|
||||
return true;
|
||||
},
|
||||
saveSyncConfig() {
|
||||
saveSyncConfigImpl.call(this);
|
||||
},
|
||||
notifyStateChange() {},
|
||||
startAutoSync() {},
|
||||
stopAutoSync() {
|
||||
stopCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
// Simulate post-upload version bump save (default: preferences from storage)
|
||||
saveSyncConfigImpl.call(manager);
|
||||
|
||||
const savedConfig = storage.get(SYNC_STORAGE_KEYS.SYNC_CONFIG) as {
|
||||
autoSync?: boolean;
|
||||
localVersion: number;
|
||||
};
|
||||
const savedPrefs = storage.get(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as { autoSync: boolean };
|
||||
assert.equal(savedPrefs.autoSync, false, 'must not re-enable auto-sync from stale memory');
|
||||
assert.equal(savedConfig.autoSync, undefined, 'version blob must omit preference fields');
|
||||
assert.equal(savedConfig.localVersion, 4);
|
||||
assert.equal(manager.state.autoSyncEnabled, false, 'memory should adopt storage preference');
|
||||
assert.equal(stopCount, 1);
|
||||
});
|
||||
|
||||
test('version save after mid-flight preference toggle keeps autoSync=false', () => {
|
||||
const storage = new Map<string, unknown>();
|
||||
storage.set(SYNC_STORAGE_KEYS.SYNC_PREFERENCES, {
|
||||
autoSync: true,
|
||||
interval: 5,
|
||||
syncStrategy: 'smartMerge',
|
||||
});
|
||||
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
|
||||
localVersion: 3,
|
||||
localUpdatedAt: 100,
|
||||
remoteVersion: 3,
|
||||
remoteUpdatedAt: 100,
|
||||
});
|
||||
|
||||
let preferenceReads = 0;
|
||||
const manager = {
|
||||
state: {
|
||||
autoSyncEnabled: true,
|
||||
autoSyncInterval: 5,
|
||||
localVersion: 4,
|
||||
localUpdatedAt: 200,
|
||||
remoteVersion: 4,
|
||||
remoteUpdatedAt: 200,
|
||||
syncStrategy: 'smartMerge',
|
||||
securityState: 'UNLOCKED',
|
||||
},
|
||||
loadFromStorage(key: string) {
|
||||
if (key === SYNC_STORAGE_KEYS.SYNC_PREFERENCES) {
|
||||
preferenceReads += 1;
|
||||
// First existence check still sees autoSync=true; a Settings toggle
|
||||
// lands before the post-write re-read used for memory adoption.
|
||||
if (preferenceReads === 1) {
|
||||
return storage.get(key) ?? null;
|
||||
}
|
||||
return {
|
||||
autoSync: false,
|
||||
interval: 5,
|
||||
syncStrategy: 'smartMerge',
|
||||
};
|
||||
}
|
||||
return storage.get(key) ?? null;
|
||||
},
|
||||
saveToStorage(key: string, value: unknown) {
|
||||
storage.set(key, value);
|
||||
// Concurrent Settings window disables auto-sync while versions persist.
|
||||
if (key === SYNC_STORAGE_KEYS.SYNC_CONFIG) {
|
||||
storage.set(SYNC_STORAGE_KEYS.SYNC_PREFERENCES, {
|
||||
autoSync: false,
|
||||
interval: 5,
|
||||
syncStrategy: 'smartMerge',
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
notifyStateChange() {},
|
||||
startAutoSync() {},
|
||||
stopAutoSync() {},
|
||||
};
|
||||
|
||||
saveSyncConfigImpl.call(manager);
|
||||
|
||||
const savedPrefs = storage.get(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as { autoSync: boolean };
|
||||
const savedConfig = storage.get(SYNC_STORAGE_KEYS.SYNC_CONFIG) as { autoSync?: boolean };
|
||||
assert.equal(savedPrefs.autoSync, false);
|
||||
assert.equal(savedConfig.autoSync, undefined);
|
||||
assert.equal(manager.state.autoSyncEnabled, false);
|
||||
});
|
||||
|
||||
test('setAutoSync still persists the explicit preference from memory', () => {
|
||||
const storage = new Map<string, unknown>();
|
||||
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
|
||||
localVersion: 1,
|
||||
localUpdatedAt: 1,
|
||||
remoteVersion: 1,
|
||||
remoteUpdatedAt: 1,
|
||||
});
|
||||
storage.set(SYNC_STORAGE_KEYS.SYNC_PREFERENCES, {
|
||||
autoSync: true,
|
||||
interval: 5,
|
||||
syncStrategy: 'smartMerge',
|
||||
});
|
||||
|
||||
const manager = {
|
||||
state: {
|
||||
autoSyncEnabled: true,
|
||||
autoSyncInterval: 5,
|
||||
localVersion: 1,
|
||||
localUpdatedAt: 1,
|
||||
remoteVersion: 1,
|
||||
remoteUpdatedAt: 1,
|
||||
syncStrategy: 'smartMerge',
|
||||
securityState: 'UNLOCKED',
|
||||
},
|
||||
loadFromStorage(key: string) {
|
||||
return storage.get(key) ?? null;
|
||||
},
|
||||
saveToStorage(key: string, value: unknown) {
|
||||
storage.set(key, value);
|
||||
return true;
|
||||
},
|
||||
saveSyncConfig(opts?: {
|
||||
preferencesFromMemory?: boolean;
|
||||
memoryKeys?: ReadonlyArray<'autoSync' | 'interval' | 'syncStrategy'>;
|
||||
}) {
|
||||
saveSyncConfigImpl.call(this, opts);
|
||||
},
|
||||
notifyStateChange() {},
|
||||
startAutoSync() {},
|
||||
stopAutoSync() {},
|
||||
};
|
||||
|
||||
setAutoSyncImpl.call(manager, false);
|
||||
|
||||
const savedPrefs = storage.get(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as { autoSync: boolean };
|
||||
const savedConfig = storage.get(SYNC_STORAGE_KEYS.SYNC_CONFIG) as { autoSync?: boolean };
|
||||
assert.equal(savedPrefs.autoSync, false);
|
||||
assert.equal(savedConfig.autoSync, undefined);
|
||||
assert.equal(manager.state.autoSyncEnabled, false);
|
||||
});
|
||||
|
||||
test('strategy preference write does not re-enable stale autoSync from memory', () => {
|
||||
const storage = new Map<string, unknown>();
|
||||
storage.set(SYNC_STORAGE_KEYS.SYNC_PREFERENCES, {
|
||||
autoSync: false,
|
||||
interval: 15,
|
||||
syncStrategy: 'smartMerge',
|
||||
});
|
||||
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
|
||||
localVersion: 2,
|
||||
localUpdatedAt: 20,
|
||||
remoteVersion: 2,
|
||||
remoteUpdatedAt: 20,
|
||||
});
|
||||
|
||||
const manager = {
|
||||
state: {
|
||||
// Stale: another window already disabled auto-sync in storage.
|
||||
autoSyncEnabled: true,
|
||||
autoSyncInterval: 5,
|
||||
localVersion: 2,
|
||||
localUpdatedAt: 20,
|
||||
remoteVersion: 2,
|
||||
remoteUpdatedAt: 20,
|
||||
syncStrategy: 'preferLocal',
|
||||
securityState: 'UNLOCKED',
|
||||
},
|
||||
loadFromStorage(key: string) {
|
||||
return storage.get(key) ?? null;
|
||||
},
|
||||
saveToStorage(key: string, value: unknown) {
|
||||
storage.set(key, value);
|
||||
return true;
|
||||
},
|
||||
saveSyncConfig(opts?: {
|
||||
preferencesFromMemory?: boolean;
|
||||
memoryKeys?: ReadonlyArray<'autoSync' | 'interval' | 'syncStrategy'>;
|
||||
}) {
|
||||
saveSyncConfigImpl.call(this, opts);
|
||||
},
|
||||
notifyStateChange() {},
|
||||
startAutoSync() {},
|
||||
stopAutoSync() {},
|
||||
};
|
||||
|
||||
saveSyncConfigImpl.call(manager, {
|
||||
preferencesFromMemory: true,
|
||||
memoryKeys: ['syncStrategy'],
|
||||
});
|
||||
|
||||
const savedPrefs = storage.get(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as {
|
||||
autoSync: boolean;
|
||||
interval: number;
|
||||
syncStrategy: string;
|
||||
};
|
||||
assert.equal(savedPrefs.autoSync, false);
|
||||
assert.equal(savedPrefs.interval, 15);
|
||||
assert.equal(savedPrefs.syncStrategy, 'preferLocal');
|
||||
});
|
||||
|
||||
function createPersistHarness(storage: Map<string, unknown>, state: {
|
||||
autoSyncEnabled: boolean;
|
||||
autoSyncInterval: number;
|
||||
localVersion: number;
|
||||
localUpdatedAt: number;
|
||||
remoteVersion: number;
|
||||
remoteUpdatedAt: number;
|
||||
syncStrategy: string;
|
||||
securityState?: string;
|
||||
}) {
|
||||
return {
|
||||
state: {
|
||||
securityState: 'UNLOCKED',
|
||||
...state,
|
||||
},
|
||||
loadFromStorage(key: string) {
|
||||
return storage.get(key) ?? null;
|
||||
},
|
||||
saveToStorage(key: string, value: unknown) {
|
||||
storage.set(key, value);
|
||||
return true;
|
||||
},
|
||||
saveSyncConfig(opts?: {
|
||||
preferencesFromMemory?: boolean;
|
||||
memoryKeys?: ReadonlyArray<'autoSync' | 'interval' | 'syncStrategy'>;
|
||||
}) {
|
||||
saveSyncConfigImpl.call(this, opts);
|
||||
},
|
||||
notifyStateChange() {},
|
||||
startAutoSync() {},
|
||||
stopAutoSync() {},
|
||||
};
|
||||
}
|
||||
|
||||
test('version-only save does not create SYNC_PREFERENCES during first upgrade', () => {
|
||||
const storage = new Map<string, unknown>();
|
||||
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
|
||||
autoSync: true,
|
||||
interval: 15,
|
||||
syncStrategy: 'preferCloud',
|
||||
localVersion: 3,
|
||||
localUpdatedAt: 100,
|
||||
remoteVersion: 3,
|
||||
remoteUpdatedAt: 100,
|
||||
});
|
||||
|
||||
const manager = createPersistHarness(storage, {
|
||||
autoSyncEnabled: true,
|
||||
autoSyncInterval: 15,
|
||||
localVersion: 4,
|
||||
localUpdatedAt: 200,
|
||||
remoteVersion: 4,
|
||||
remoteUpdatedAt: 200,
|
||||
syncStrategy: 'preferCloud',
|
||||
});
|
||||
|
||||
saveSyncConfigImpl.call(manager);
|
||||
|
||||
assert.equal(storage.has(SYNC_STORAGE_KEYS.SYNC_PREFERENCES), false);
|
||||
const savedConfig = storage.get(SYNC_STORAGE_KEYS.SYNC_CONFIG) as {
|
||||
autoSync?: boolean;
|
||||
interval?: number;
|
||||
syncStrategy?: string;
|
||||
localVersion: number;
|
||||
};
|
||||
assert.equal(savedConfig.autoSync, true);
|
||||
assert.equal(savedConfig.interval, 15);
|
||||
assert.equal(savedConfig.syncStrategy, 'preferCloud');
|
||||
assert.equal(savedConfig.localVersion, 4);
|
||||
});
|
||||
|
||||
test('version-only save preserves stored legacy autoSync=false over stale memory', () => {
|
||||
const storage = new Map<string, unknown>();
|
||||
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
|
||||
autoSync: false,
|
||||
interval: 5,
|
||||
syncStrategy: 'smartMerge',
|
||||
localVersion: 3,
|
||||
localUpdatedAt: 100,
|
||||
remoteVersion: 3,
|
||||
remoteUpdatedAt: 100,
|
||||
});
|
||||
|
||||
const manager = createPersistHarness(storage, {
|
||||
autoSyncEnabled: true,
|
||||
autoSyncInterval: 5,
|
||||
localVersion: 4,
|
||||
localUpdatedAt: 200,
|
||||
remoteVersion: 4,
|
||||
remoteUpdatedAt: 200,
|
||||
syncStrategy: 'smartMerge',
|
||||
});
|
||||
|
||||
saveSyncConfigImpl.call(manager);
|
||||
|
||||
assert.equal(storage.has(SYNC_STORAGE_KEYS.SYNC_PREFERENCES), false);
|
||||
const savedConfig = storage.get(SYNC_STORAGE_KEYS.SYNC_CONFIG) as { autoSync?: boolean };
|
||||
assert.equal(savedConfig.autoSync, false);
|
||||
assert.equal(manager.state.autoSyncEnabled, false);
|
||||
});
|
||||
|
||||
test('version-only save cannot overwrite a concurrent first-upgrade autoSync=false', () => {
|
||||
const storage = new Map<string, unknown>();
|
||||
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
|
||||
autoSync: true,
|
||||
interval: 5,
|
||||
syncStrategy: 'smartMerge',
|
||||
localVersion: 3,
|
||||
localUpdatedAt: 100,
|
||||
remoteVersion: 3,
|
||||
remoteUpdatedAt: 100,
|
||||
});
|
||||
|
||||
let preferenceReads = 0;
|
||||
const manager = {
|
||||
...createPersistHarness(storage, {
|
||||
autoSyncEnabled: true,
|
||||
autoSyncInterval: 5,
|
||||
localVersion: 4,
|
||||
localUpdatedAt: 200,
|
||||
remoteVersion: 4,
|
||||
remoteUpdatedAt: 200,
|
||||
syncStrategy: 'smartMerge',
|
||||
}),
|
||||
loadFromStorage(key: string) {
|
||||
if (key === SYNC_STORAGE_KEYS.SYNC_PREFERENCES) {
|
||||
preferenceReads += 1;
|
||||
// Settings toggle lands after the existence check and before the
|
||||
// post-write reread — the previous migrate wrote stale autoSync=true
|
||||
// into this key and clobbered the toggle.
|
||||
if (preferenceReads === 1) return null;
|
||||
return storage.get(key) ?? null;
|
||||
}
|
||||
return storage.get(key) ?? null;
|
||||
},
|
||||
saveToStorage(key: string, value: unknown) {
|
||||
storage.set(key, value);
|
||||
if (key === SYNC_STORAGE_KEYS.SYNC_CONFIG) {
|
||||
storage.set(SYNC_STORAGE_KEYS.SYNC_PREFERENCES, {
|
||||
autoSync: false,
|
||||
interval: 5,
|
||||
syncStrategy: 'smartMerge',
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
saveSyncConfigImpl.call(manager);
|
||||
|
||||
const savedPrefs = storage.get(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as { autoSync: boolean };
|
||||
assert.equal(savedPrefs.autoSync, false, 'must not migrate stale autoSync=true over a concurrent toggle');
|
||||
assert.equal(manager.state.autoSyncEnabled, false);
|
||||
});
|
||||
|
||||
test('first-upgrade setAutoSync(false) then version save does not re-enable', () => {
|
||||
const storage = new Map<string, unknown>();
|
||||
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
|
||||
autoSync: true,
|
||||
interval: 5,
|
||||
syncStrategy: 'smartMerge',
|
||||
localVersion: 3,
|
||||
localUpdatedAt: 100,
|
||||
remoteVersion: 3,
|
||||
remoteUpdatedAt: 100,
|
||||
});
|
||||
|
||||
const manager = createPersistHarness(storage, {
|
||||
autoSyncEnabled: true,
|
||||
autoSyncInterval: 5,
|
||||
localVersion: 3,
|
||||
localUpdatedAt: 100,
|
||||
remoteVersion: 3,
|
||||
remoteUpdatedAt: 100,
|
||||
syncStrategy: 'smartMerge',
|
||||
});
|
||||
|
||||
setAutoSyncImpl.call(manager, false);
|
||||
manager.state.localVersion = 4;
|
||||
manager.state.localUpdatedAt = 200;
|
||||
saveSyncConfigImpl.call(manager);
|
||||
|
||||
const savedPrefs = storage.get(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as { autoSync: boolean };
|
||||
const savedConfig = storage.get(SYNC_STORAGE_KEYS.SYNC_CONFIG) as {
|
||||
autoSync?: boolean;
|
||||
localVersion: number;
|
||||
};
|
||||
assert.equal(savedPrefs.autoSync, false);
|
||||
assert.equal(savedConfig.autoSync, undefined);
|
||||
assert.equal(savedConfig.localVersion, 4);
|
||||
assert.equal(manager.state.autoSyncEnabled, false);
|
||||
});
|
||||
151
infrastructure/services/cloudSync/syncConfigPersist.test.ts
Normal file
151
infrastructure/services/cloudSync/syncConfigPersist.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
coalesceStoredSyncPreferences,
|
||||
hasSyncPreferenceFields,
|
||||
resolveSyncConfigForPersist,
|
||||
resolveSyncPreferencesForPersist,
|
||||
resolveSyncVersionsForPersist,
|
||||
} from './syncConfigPersist.ts';
|
||||
|
||||
const memoryBase = {
|
||||
autoSync: true,
|
||||
interval: 5,
|
||||
localVersion: 12,
|
||||
localUpdatedAt: 1000,
|
||||
remoteVersion: 11,
|
||||
remoteUpdatedAt: 900,
|
||||
syncStrategy: 'smartMerge' as const,
|
||||
};
|
||||
|
||||
test('version-only persist keeps another window autoSync=false from storage', () => {
|
||||
const next = resolveSyncConfigForPersist({
|
||||
memory: memoryBase,
|
||||
stored: {
|
||||
autoSync: false,
|
||||
interval: 5,
|
||||
localVersion: 10,
|
||||
localUpdatedAt: 800,
|
||||
remoteVersion: 10,
|
||||
remoteUpdatedAt: 800,
|
||||
syncStrategy: 'smartMerge',
|
||||
},
|
||||
preferencesFromMemory: false,
|
||||
});
|
||||
|
||||
assert.equal(next.autoSync, false);
|
||||
assert.equal(next.localVersion, 12);
|
||||
assert.equal(next.remoteVersion, 11);
|
||||
});
|
||||
|
||||
test('explicit preference persist writes memory autoSync', () => {
|
||||
const next = resolveSyncConfigForPersist({
|
||||
memory: { ...memoryBase, autoSync: false },
|
||||
stored: {
|
||||
autoSync: true,
|
||||
interval: 5,
|
||||
localVersion: 10,
|
||||
localUpdatedAt: 800,
|
||||
remoteVersion: 10,
|
||||
remoteUpdatedAt: 800,
|
||||
syncStrategy: 'smartMerge',
|
||||
},
|
||||
preferencesFromMemory: true,
|
||||
});
|
||||
|
||||
assert.equal(next.autoSync, false);
|
||||
assert.equal(next.localVersion, 12);
|
||||
});
|
||||
|
||||
test('strategy-only preference persist keeps stored autoSync', () => {
|
||||
const next = resolveSyncPreferencesForPersist({
|
||||
memory: {
|
||||
autoSync: true,
|
||||
interval: 5,
|
||||
syncStrategy: 'preferLocal',
|
||||
},
|
||||
stored: {
|
||||
autoSync: false,
|
||||
interval: 15,
|
||||
syncStrategy: 'smartMerge',
|
||||
},
|
||||
preferencesFromMemory: true,
|
||||
memoryKeys: ['syncStrategy'],
|
||||
});
|
||||
|
||||
assert.equal(next.autoSync, false);
|
||||
assert.equal(next.interval, 15);
|
||||
assert.equal(next.syncStrategy, 'preferLocal');
|
||||
});
|
||||
|
||||
test('autoSync-only preference persist keeps stored syncStrategy', () => {
|
||||
const next = resolveSyncPreferencesForPersist({
|
||||
memory: {
|
||||
autoSync: false,
|
||||
interval: 5,
|
||||
syncStrategy: 'smartMerge',
|
||||
},
|
||||
stored: {
|
||||
autoSync: true,
|
||||
interval: 15,
|
||||
syncStrategy: 'preferCloud',
|
||||
},
|
||||
preferencesFromMemory: true,
|
||||
memoryKeys: ['autoSync'],
|
||||
});
|
||||
|
||||
assert.equal(next.autoSync, false);
|
||||
assert.equal(next.interval, 15);
|
||||
assert.equal(next.syncStrategy, 'preferCloud');
|
||||
});
|
||||
|
||||
test('version-only persist keeps stored syncStrategy when memory differs', () => {
|
||||
const next = resolveSyncConfigForPersist({
|
||||
memory: { ...memoryBase, syncStrategy: 'preferLocal' },
|
||||
stored: {
|
||||
autoSync: false,
|
||||
interval: 15,
|
||||
syncStrategy: 'preferCloud',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(next.autoSync, false);
|
||||
assert.equal(next.interval, 15);
|
||||
assert.equal(next.syncStrategy, 'preferCloud');
|
||||
});
|
||||
|
||||
test('coalesce prefers dedicated preferences over legacy SYNC_CONFIG fields', () => {
|
||||
const coalesced = coalesceStoredSyncPreferences(
|
||||
{ autoSync: false, interval: 15, syncStrategy: 'preferCloud' },
|
||||
{ autoSync: true, interval: 5, syncStrategy: 'smartMerge' },
|
||||
);
|
||||
assert.equal(coalesced?.autoSync, false);
|
||||
assert.equal(coalesced?.interval, 15);
|
||||
});
|
||||
|
||||
test('hasSyncPreferenceFields ignores version-only blobs', () => {
|
||||
assert.equal(hasSyncPreferenceFields(null), false);
|
||||
assert.equal(hasSyncPreferenceFields({}), false);
|
||||
assert.equal(hasSyncPreferenceFields({ autoSync: false }), true);
|
||||
assert.equal(hasSyncPreferenceFields({ interval: 15 }), true);
|
||||
assert.equal(hasSyncPreferenceFields({ syncStrategy: 'preferCloud' }), true);
|
||||
});
|
||||
|
||||
test('preference and version resolvers stay independent', () => {
|
||||
const prefs = resolveSyncPreferencesForPersist({
|
||||
memory: { autoSync: true, interval: 5, syncStrategy: 'smartMerge' },
|
||||
stored: { autoSync: false, interval: 15, syncStrategy: 'preferCloud' },
|
||||
preferencesFromMemory: false,
|
||||
});
|
||||
const versions = resolveSyncVersionsForPersist({
|
||||
localVersion: 9,
|
||||
localUpdatedAt: 1,
|
||||
remoteVersion: 8,
|
||||
remoteUpdatedAt: 2,
|
||||
});
|
||||
assert.equal(prefs.autoSync, false);
|
||||
assert.equal(versions.localVersion, 9);
|
||||
assert.equal('localVersion' in prefs, false);
|
||||
assert.equal('autoSync' in versions, false);
|
||||
});
|
||||
132
infrastructure/services/cloudSync/syncConfigPersist.ts
Normal file
132
infrastructure/services/cloudSync/syncConfigPersist.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
DEFAULT_CLOUD_SYNC_STRATEGY,
|
||||
normalizeCloudSyncStrategy,
|
||||
type CloudSyncStrategy,
|
||||
} from '../../../domain/syncStrategy';
|
||||
|
||||
export type SyncPreferencePersistFields = {
|
||||
autoSync: boolean;
|
||||
interval: number;
|
||||
syncStrategy: CloudSyncStrategy;
|
||||
};
|
||||
|
||||
export type SyncPreferenceKey = keyof SyncPreferencePersistFields;
|
||||
|
||||
export type SyncVersionPersistFields = {
|
||||
localVersion: number;
|
||||
localUpdatedAt: number;
|
||||
remoteVersion: number;
|
||||
remoteUpdatedAt: number;
|
||||
};
|
||||
|
||||
export type SyncConfigPersistFields = SyncPreferencePersistFields & SyncVersionPersistFields;
|
||||
|
||||
/**
|
||||
* Prefer the dedicated preferences key; fall back to legacy fields still
|
||||
* embedded in SYNC_CONFIG from older builds.
|
||||
*/
|
||||
export function coalesceStoredSyncPreferences(
|
||||
preferences: Partial<SyncPreferencePersistFields> | null | undefined,
|
||||
legacyConfig: Partial<SyncPreferencePersistFields> | null | undefined,
|
||||
): Partial<SyncPreferencePersistFields> | null {
|
||||
if (preferences && typeof preferences === 'object') return preferences;
|
||||
if (legacyConfig && typeof legacyConfig === 'object') return legacyConfig;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasSyncPreferenceFields(
|
||||
value: Partial<SyncPreferencePersistFields> | null | undefined,
|
||||
): boolean {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
return value.autoSync !== undefined
|
||||
|| value.interval !== undefined
|
||||
|| value.syncStrategy !== undefined;
|
||||
}
|
||||
|
||||
export function resolveSyncPreferencesForPersist(input: {
|
||||
memory: SyncPreferencePersistFields;
|
||||
stored: Partial<SyncPreferencePersistFields> | null | undefined;
|
||||
/**
|
||||
* When true, preference fields come from this window's memory — used by
|
||||
* setAutoSync / setSyncStrategy. When false (default), preference fields
|
||||
* are taken from storage so a version-only save cannot invent prefs.
|
||||
*/
|
||||
preferencesFromMemory?: boolean;
|
||||
/**
|
||||
* When preferencesFromMemory is true, only these keys are taken from
|
||||
* memory; other preference fields keep the stored value so a setter in
|
||||
* one window cannot clobber a concurrent preference edit from another.
|
||||
* Omit to take every preference field from memory (full snapshot).
|
||||
*/
|
||||
memoryKeys?: readonly SyncPreferenceKey[];
|
||||
}): SyncPreferencePersistFields {
|
||||
const { memory, stored, preferencesFromMemory = false, memoryKeys } = input;
|
||||
const fromStorage = stored && typeof stored === 'object' ? stored : null;
|
||||
|
||||
const takeFromMemory = (key: SyncPreferenceKey): boolean => {
|
||||
if (!fromStorage) return true;
|
||||
if (!preferencesFromMemory) return false;
|
||||
if (!memoryKeys) return true;
|
||||
return memoryKeys.includes(key);
|
||||
};
|
||||
|
||||
return {
|
||||
autoSync: Boolean(
|
||||
takeFromMemory('autoSync')
|
||||
? memory.autoSync
|
||||
: fromStorage?.autoSync !== undefined
|
||||
? fromStorage.autoSync
|
||||
: memory.autoSync,
|
||||
),
|
||||
interval: Number(
|
||||
takeFromMemory('interval')
|
||||
? memory.interval
|
||||
: fromStorage?.interval !== undefined
|
||||
? fromStorage.interval
|
||||
: memory.interval,
|
||||
),
|
||||
syncStrategy: normalizeCloudSyncStrategy(
|
||||
takeFromMemory('syncStrategy')
|
||||
? memory.syncStrategy
|
||||
: fromStorage?.syncStrategy !== undefined
|
||||
? fromStorage.syncStrategy
|
||||
: memory.syncStrategy ?? DEFAULT_CLOUD_SYNC_STRATEGY,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSyncVersionsForPersist(
|
||||
memory: SyncVersionPersistFields,
|
||||
): SyncVersionPersistFields {
|
||||
return {
|
||||
localVersion: Number(memory.localVersion),
|
||||
localUpdatedAt: Number(memory.localUpdatedAt),
|
||||
remoteVersion: Number(memory.remoteVersion),
|
||||
remoteUpdatedAt: Number(memory.remoteUpdatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Prefer resolveSyncPreferencesForPersist + resolveSyncVersionsForPersist.
|
||||
* Kept for unit tests that assert the combined legacy shape.
|
||||
*/
|
||||
export function resolveSyncConfigForPersist(input: {
|
||||
memory: SyncConfigPersistFields;
|
||||
stored: Partial<SyncConfigPersistFields> | null | undefined;
|
||||
preferencesFromMemory?: boolean;
|
||||
memoryKeys?: readonly SyncPreferenceKey[];
|
||||
}): SyncConfigPersistFields {
|
||||
return {
|
||||
...resolveSyncPreferencesForPersist({
|
||||
memory: {
|
||||
autoSync: input.memory.autoSync,
|
||||
interval: input.memory.interval,
|
||||
syncStrategy: input.memory.syncStrategy,
|
||||
},
|
||||
stored: input.stored,
|
||||
preferencesFromMemory: input.preferencesFromMemory,
|
||||
memoryKeys: input.memoryKeys,
|
||||
}),
|
||||
...resolveSyncVersionsForPersist(input.memory),
|
||||
};
|
||||
}
|
||||
162
infrastructure/services/cloudSync/syncConfigStorageEvent.test.ts
Normal file
162
infrastructure/services/cloudSync/syncConfigStorageEvent.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { SYNC_CONSTANTS, SYNC_STORAGE_KEYS } from '../../../domain/sync.ts';
|
||||
import { handleStorageEventImpl } from './stateAndSecurityMethods.ts';
|
||||
|
||||
test('SYNC_PREFERENCES storage event stops auto-sync when another window disables it', () => {
|
||||
const fakeStorage = {};
|
||||
const originalWindow = globalThis.window;
|
||||
let stopCount = 0;
|
||||
let startCount = 0;
|
||||
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = {
|
||||
localStorage: fakeStorage,
|
||||
};
|
||||
|
||||
const manager = {
|
||||
state: {
|
||||
autoSyncEnabled: true,
|
||||
autoSyncInterval: 5,
|
||||
localVersion: 1,
|
||||
localUpdatedAt: 1,
|
||||
remoteVersion: 1,
|
||||
remoteUpdatedAt: 1,
|
||||
syncStrategy: 'smartMerge',
|
||||
securityState: 'UNLOCKED',
|
||||
},
|
||||
loadFromStorage: () => null,
|
||||
safeJsonParse: (value: string | null) => (value ? JSON.parse(value) : null),
|
||||
startAutoSync: () => {
|
||||
startCount += 1;
|
||||
},
|
||||
stopAutoSync: () => {
|
||||
stopCount += 1;
|
||||
},
|
||||
notifyStateChange: () => {},
|
||||
};
|
||||
|
||||
try {
|
||||
handleStorageEventImpl.call(manager, {
|
||||
storageArea: fakeStorage,
|
||||
key: SYNC_STORAGE_KEYS.SYNC_PREFERENCES,
|
||||
newValue: JSON.stringify({
|
||||
autoSync: false,
|
||||
interval: SYNC_CONSTANTS.DEFAULT_AUTO_SYNC_INTERVAL,
|
||||
syncStrategy: 'smartMerge',
|
||||
}),
|
||||
} as StorageEvent);
|
||||
} finally {
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
|
||||
assert.equal(manager.state.autoSyncEnabled, false);
|
||||
assert.equal(stopCount, 1);
|
||||
assert.equal(startCount, 0);
|
||||
});
|
||||
|
||||
test('version-only SYNC_CONFIG storage event does not rewrite preferences', () => {
|
||||
const fakeStorage = {};
|
||||
const originalWindow = globalThis.window;
|
||||
let stopCount = 0;
|
||||
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = {
|
||||
localStorage: fakeStorage,
|
||||
};
|
||||
|
||||
const manager = {
|
||||
state: {
|
||||
autoSyncEnabled: false,
|
||||
autoSyncInterval: 5,
|
||||
localVersion: 1,
|
||||
localUpdatedAt: 1,
|
||||
remoteVersion: 1,
|
||||
remoteUpdatedAt: 1,
|
||||
syncStrategy: 'smartMerge',
|
||||
securityState: 'UNLOCKED',
|
||||
},
|
||||
loadFromStorage: (key: string) => {
|
||||
if (key === SYNC_STORAGE_KEYS.SYNC_PREFERENCES) {
|
||||
return { autoSync: false, interval: 5, syncStrategy: 'smartMerge' };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
safeJsonParse: (value: string | null) => (value ? JSON.parse(value) : null),
|
||||
startAutoSync: () => {},
|
||||
stopAutoSync: () => {
|
||||
stopCount += 1;
|
||||
},
|
||||
notifyStateChange: () => {},
|
||||
};
|
||||
|
||||
try {
|
||||
handleStorageEventImpl.call(manager, {
|
||||
storageArea: fakeStorage,
|
||||
key: SYNC_STORAGE_KEYS.SYNC_CONFIG,
|
||||
newValue: JSON.stringify({
|
||||
localVersion: 2,
|
||||
localUpdatedAt: 2,
|
||||
remoteVersion: 2,
|
||||
remoteUpdatedAt: 2,
|
||||
}),
|
||||
} as StorageEvent);
|
||||
} finally {
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
|
||||
assert.equal(manager.state.autoSyncEnabled, false);
|
||||
assert.equal(manager.state.localVersion, 2);
|
||||
assert.equal(stopCount, 0);
|
||||
});
|
||||
|
||||
test('legacy combined SYNC_CONFIG storage event still applies preferences when prefs key absent', () => {
|
||||
const fakeStorage = {};
|
||||
const originalWindow = globalThis.window;
|
||||
let stopCount = 0;
|
||||
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = {
|
||||
localStorage: fakeStorage,
|
||||
};
|
||||
|
||||
const manager = {
|
||||
state: {
|
||||
autoSyncEnabled: true,
|
||||
autoSyncInterval: 5,
|
||||
localVersion: 1,
|
||||
localUpdatedAt: 1,
|
||||
remoteVersion: 1,
|
||||
remoteUpdatedAt: 1,
|
||||
syncStrategy: 'smartMerge',
|
||||
securityState: 'UNLOCKED',
|
||||
},
|
||||
loadFromStorage: () => null,
|
||||
safeJsonParse: (value: string | null) => (value ? JSON.parse(value) : null),
|
||||
startAutoSync: () => {},
|
||||
stopAutoSync: () => {
|
||||
stopCount += 1;
|
||||
},
|
||||
notifyStateChange: () => {},
|
||||
};
|
||||
|
||||
try {
|
||||
handleStorageEventImpl.call(manager, {
|
||||
storageArea: fakeStorage,
|
||||
key: SYNC_STORAGE_KEYS.SYNC_CONFIG,
|
||||
newValue: JSON.stringify({
|
||||
autoSync: false,
|
||||
interval: SYNC_CONSTANTS.DEFAULT_AUTO_SYNC_INTERVAL,
|
||||
localVersion: 2,
|
||||
localUpdatedAt: 2,
|
||||
remoteVersion: 2,
|
||||
remoteUpdatedAt: 2,
|
||||
syncStrategy: 'smartMerge',
|
||||
}),
|
||||
} as StorageEvent);
|
||||
} finally {
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
|
||||
assert.equal(manager.state.autoSyncEnabled, false);
|
||||
assert.equal(manager.state.localVersion, 2);
|
||||
assert.equal(stopCount, 1);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EncryptionService } from '../EncryptionService';
|
||||
import { unlockImpl } from './stateAndSecurityMethods';
|
||||
|
||||
for (const scenario of ['rotated-valid-password', 'rotated-invalid-old-password', 'locked-during-unlock'] as const) {
|
||||
test(`unlock rejects a superseded request instead of reporting a bad password: ${scenario}`, async () => {
|
||||
const oldConfig = await EncryptionService.createMasterKeyConfig('old-fixture-password');
|
||||
const newConfig = await EncryptionService.createMasterKeyConfig('new-fixture-password');
|
||||
let generation = 0;
|
||||
const manager = {
|
||||
state: { masterKeyConfig: oldConfig, securityState: 'LOCKED', unlockedKey: null },
|
||||
getSyncSecurityGeneration: () => generation,
|
||||
};
|
||||
const pending = unlockImpl.call(manager,
|
||||
scenario === 'rotated-invalid-old-password' ? 'new-fixture-password' : 'old-fixture-password');
|
||||
if (scenario !== 'locked-during-unlock') manager.state.masterKeyConfig = newConfig;
|
||||
generation += 1;
|
||||
await assert.rejects(pending, /changed while unlocking/i);
|
||||
assert.equal(manager.state.securityState, 'LOCKED');
|
||||
assert.equal(manager.state.unlockedKey, null);
|
||||
});
|
||||
}
|
||||
|
||||
test('a wrong password for the current configuration remains an ordinary verification failure', async () => {
|
||||
const config = await EncryptionService.createMasterKeyConfig('correct-fixture-password');
|
||||
const manager = { state: { masterKeyConfig: config, securityState: 'LOCKED', unlockedKey: null } };
|
||||
assert.equal(await unlockImpl.call(manager, 'wrong-fixture-password'), false);
|
||||
assert.equal(manager.state.unlockedKey, null);
|
||||
});
|
||||
Reference in New Issue
Block a user