Files
Netcatty/infrastructure/services/syncAnchorDecision.test.mjs
T
陈大猫 db69d5ac39 [codex] Harden sync overwrite protection and add local restore history (#720)
* fix: harden sync overwrite recovery

* refactor: separate backup retention settings

* refactor: align backup retention controls

* refactor: simplify backup retention card

* fix: address PR #720 deep-review findings

- Close the cross-window restore race by holding a time-bounded barrier
  in localStorage during every destructive apply; useAutoSync skips
  pushes while it's set, preventing a pre-restore snapshot from
  clobbering just-restored cloud data.
- Round-trip startup three-way merges so merged-in local additions
  actually reach the cloud instead of living only on the device that
  ran the merge until the next edit.
- Upgrade sync signatures from a 64-char ciphertext prefix to full
  SHA-256 (v3), closing the tail-mutation replay weakness.
- Harden the vault-backup IPC: payload size cap, enum-validated reason,
  sanitized version strings, strict maxCount, concurrent-call mutex,
  monotonic createdAt to avoid same-ms ordering ties.
- Extract the anchor-change decision into a pure module with unit tests
  covering no-anchor, resource-id drift, and signature mismatch paths.
- Capture the protective backup from the pre-apply closure snapshot so
  it reflects what's being replaced rather than what was imported.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR #720 follow-up review findings

Make protective backup abort-on-failure (was best-effort console.error),
preserve nested syncedAt in fingerprint, use UTF-8 byte length for size
guard, throw on conflict-inspect failure so stale uploads can't leak
through, treat unreadable remote as changed, canonical-JSON signature
meta, and hold the version stamp on transient backup failures so the
retry path still fires.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address second-pass review findings on PR #720

- Hold version-change stamp when payload is non-meaningful (covers the
  startup vault-rehydrate race where a transient empty snapshot would
  permanently skip the upgrade backup).
- readBackupRecord stat-checks before readFile so an oversized file in
  the backup dir cannot OOM the renderer on enumeration.
- Reject maxBackups input outside 1..100 instead of silently clamping
  (matches the i18n error copy and the main-process sanitizer bound).
- Wrap USE_LOCAL conflict-resolution push in withRestoreBarrier so a
  concurrent auto-sync in another window cannot interleave.
- sha256Hex throws SyncSignatureUnavailableError on missing WebCrypto
  subtle; createSyncedFileSignature returns null, forcing the
  unreadable-remote → three-way-merge path instead of a weak
  length-only pseudo-signature.
- Document that array order in normalizePayloadForHash is an invariant
  enforced by producers, not the hash function.
- Drop three-way-merge completion logs from console.log to console.info.
- Comment the implicit restore → store-listener refresh chain so
  future refactors don't silently break the UI reload path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address third-pass review findings on PR #720

Resolves I-3 through I-8 and related cleanup items identified in the
deep review. Highlights:

- replace setTimeout(0) post-merge round-trip with a direct
  syncAllProviders call using the already-computed merged payload,
  removing the React-commit race
- resolve the empty-vault confirmation promise on unmount so a
  mid-dialog window teardown doesn't leak the resolver
- retry the version-change backup as hosts/keys hydrate, instead of
  latching on the first (possibly empty) snapshot
- heartbeat-refresh the cross-window restore barrier so long applies
  cannot expose a post-60s window to concurrent auto-sync
- add a diagnostic warning when connected providers hold divergent
  bases (multi-account configurations)
- surface a user-visible "Sync paused" toast when startup inspect
  fails, replacing the previous silent gate-open
- tie-break backup list sort by id when createdAt collides
- extract applyProtectedSyncPayload so the main and settings windows
  cannot drift on restore-barrier / protective-backup handling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address deep-review findings on PR #720

Deep re-review surfaced six Important issues that survived the prior
four review rounds. All are hardened here:

- I1: fsync the protective backup file AND its directory before the
  rename completes, so a system crash between backup creation and the
  restore it guards cannot leave a torn/zero-length safety net.
- I3: persist an apply-in-progress sentinel across the non-atomic
  localStorage writes in applySyncPayload. A crash mid-apply now
  surfaces on the next startup (toast + refuse auto-push) instead of
  silently pushing the half-applied state over an intact cloud copy.
- I2: only open the auto-sync gate (remoteCheckDoneRef) when the
  startup inspect validated cleanly. Add a bounded exponential-backoff
  retry so a transient inspect failure self-heals instead of wedging
  auto-sync until restart.
- I5: save the sync base BEFORE advancing the per-provider anchor
  inside uploadToProvider. A renderer crash between the two writes
  now degrades to "stale anchor forces re-inspect on next run," which
  re-merges against the fresh base — eliminating the silent
  base-drift window where a 3rd-device race could misclassify
  entries.
- I6: main process broadcasts a vaultBackups:changed IPC event on
  every mutation; useLocalVaultBackups subscribes so protective
  backups created from the main window show up in the Settings
  backup list without manual refresh.
- I4: update PR description + code comment to match the actual
  (safer) design: auto-sync gate opens on vault init, with
  hasMeaningfulSyncData + restore barrier preventing empty-push; the
  version-change backup is best-effort and retries as data hydrates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: serialize startup checkRemoteVersion and stabilize its deps

Re-review flagged that checkRemoteVersion's useCallback depended on
`config` — a fresh object literal from App.tsx on every render — so
the retry effect restarted with attempt=0 on every vault edit and
could spawn overlapping in-flight inspect+apply runs. Two concurrent
commitRemoteInspection + onApplyPayload calls could race on the
apply-in-progress sentinel around interleaved writes.

Route `buildPayload`, `config.onApplyPayload`, and `config.startupReady`
through refs so checkRemoteVersion's identity no longer churns with
unrelated App state. Add an in-flight guard that returns early when a
previous invocation is still awaiting the network, closing the
same-window re-entry gap that withRestoreBarrier intentionally doesn't
cover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: release in-flight lock on no-connected-provider early return

Third-pass review caught that `checkRemoteInFlightRef` was acquired
before the `!connectedProvider` check, so that early return leaked
the lock and every subsequent retry-timer tick silently no-op'd.
Move the acquisition past the early return so the only path that
takes the lock reaches the finally-release.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 03:09:55 +08:00

214 lines
8.8 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { decideRemoteChanged } from './syncAnchorDecision.js';
// -----------------------------------------------------------------------
// Anchor-missing branches
// -----------------------------------------------------------------------
test('no anchor + empty remote → not changed (first sync with empty cloud)', () => {
const result = decideRemoteChanged({
currentSignature: null,
currentResourceId: null,
anchor: null,
hasRemoteFile: false,
});
assert.equal(result.remoteChanged, false);
assert.equal(result.reason, 'no-anchor-no-remote');
});
test('no anchor + non-empty remote → changed (first sync with data in cloud)', () => {
// Critical: this is the "new device with existing cloud vault" path.
// Returning not-changed here would silently skip the three-way merge
// and let an empty local push clobber remote.
const result = decideRemoteChanged({
currentSignature: 'v3:sig-remote',
currentResourceId: 'gist-1',
anchor: null,
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'no-anchor-remote-has-data');
});
test('no anchor + hasRemoteFile true but null signature → changed (unreadable remote, C3)', () => {
// Previously this returned `remoteChanged: false`, which silently
// routed callers down the "nothing to merge" short-circuit and then
// let the upload path stomp the malformed-but-extant remote file on
// the next push. Treating an unreadable remote as "changed" forces the
// three-way-merge branch — if the payload is decryptable the merge
// succeeds, and if it isn't the decrypt error surfaces to the user
// instead of being silently papered over by an overwrite.
const result = decideRemoteChanged({
currentSignature: null,
currentResourceId: 'gist-1',
anchor: null,
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'unreadable-remote');
});
// -----------------------------------------------------------------------
// Anchor-matches branches
// -----------------------------------------------------------------------
test('anchor matches signature and resourceId → not changed', () => {
const result = decideRemoteChanged({
currentSignature: 'v3:sig-A',
currentResourceId: 'gist-1',
anchor: { signature: 'v3:sig-A', resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, false);
assert.equal(result.reason, 'anchor-matches');
});
// -----------------------------------------------------------------------
// Anchor-stale branches
// -----------------------------------------------------------------------
test('anchor signature mismatch → changed', () => {
const result = decideRemoteChanged({
currentSignature: 'v3:sig-NEW',
currentResourceId: 'gist-1',
anchor: { signature: 'v3:sig-OLD', resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'signature-mismatch');
});
test('anchor resourceId mismatch → changed (even when signatures happen to match)', () => {
// Provider created a fresh file (gist recreated, Drive file recreated).
// The old anchor's signature is meaningless once the resource id drifts.
const result = decideRemoteChanged({
currentSignature: 'v3:sig-SAME',
currentResourceId: 'gist-NEW',
anchor: { signature: 'v3:sig-SAME', resourceId: 'gist-OLD' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'resource-id-changed');
});
test('anchor resourceId was null, now has value → changed', () => {
// Before: user connected but first-sync had no resource yet.
// Now: provider returned a concrete id. Treat as changed so the
// follow-up re-inspects correctly.
const result = decideRemoteChanged({
currentSignature: 'v3:sig-A',
currentResourceId: 'gist-1',
anchor: { signature: 'v3:sig-A', resourceId: null },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'resource-id-changed');
});
test('anchor resourceId had value, now null → changed', () => {
// Adapter lost the resource id somehow (disconnect, re-login). The
// old signature-based comparison is not trustworthy here.
const result = decideRemoteChanged({
currentSignature: 'v3:sig-A',
currentResourceId: null,
anchor: { signature: 'v3:sig-A', resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'resource-id-changed');
});
// -----------------------------------------------------------------------
// Defensive shapes
// -----------------------------------------------------------------------
test('anchor with undefined signature → changed unless current is also null', () => {
// `anchor.signature` missing (pre-v2 persisted record, say) and
// `currentSignature` non-null → must not treat as match.
const changed = decideRemoteChanged({
currentSignature: 'v3:sig',
currentResourceId: 'id-1',
anchor: { resourceId: 'id-1' },
hasRemoteFile: true,
});
assert.equal(changed.remoteChanged, true);
assert.equal(changed.reason, 'signature-mismatch');
});
test('anchor signature null and current signature null with same resourceId → not changed', () => {
// The legitimate "empty-on-both-sides already observed" case.
const result = decideRemoteChanged({
currentSignature: null,
currentResourceId: 'id-1',
anchor: { signature: null, resourceId: 'id-1' },
hasRemoteFile: false,
});
assert.equal(result.remoteChanged, false);
assert.equal(result.reason, 'anchor-matches');
});
// -----------------------------------------------------------------------
// Migration: stored v2 anchor → fresh v3 signature from this build
// -----------------------------------------------------------------------
test('v2 anchor persisted from older build → signature-mismatch against v3 (migration)', () => {
// A user upgrading from a build that persisted `v2:<prefix-hash>` must
// see the next startup inspection treat the remote as "changed". The
// v3 signature format is `v3:{...meta}|len=...|sha256=...`; the two
// strings can never compare equal, so the decision routes through
// three-way merge and re-observes the remote. Without this property
// a stale v2 anchor would be treated as authoritative, skipping the
// merge and letting local-only state overwrite remote — the very
// #711/#719 failure path.
const result = decideRemoteChanged({
currentSignature: 'v3:{"appVersion":"1.0.0"}|len=80|sha256=' + 'a'.repeat(64),
currentResourceId: 'gist-1',
anchor: { signature: 'v2:abcdef1234567890', resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'signature-mismatch');
});
// -----------------------------------------------------------------------
// Regression: issues #711 / #719 — stale-device-overwrites-newer-remote
// -----------------------------------------------------------------------
test('stale device sees fresh remote → triggers merge, not overwrite (#711/#719)', () => {
// Scenario: Device A syncs at T0, anchor records signature sigA.
// User edits on Device B at T1 → remote signature becomes sigB.
// Device A then wakes up with a stale anchor (sigA) and the fresh
// remote (sigB). The decision MUST say "remote changed" so the
// sync path three-way merges Device A's local into remote instead
// of short-circuiting to "no change" and overwriting Device B's edit.
const sigA = 'v3:{"updatedAt":1700000000000}|len=80|sha256=' + 'a'.repeat(64);
const sigB = 'v3:{"updatedAt":1700000300000}|len=80|sha256=' + 'b'.repeat(64);
const result = decideRemoteChanged({
currentSignature: sigB,
currentResourceId: 'gist-1',
anchor: { signature: sigA, resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'signature-mismatch');
});
test('fresh device, same-signature anchor → no spurious merge (#711/#719 inverse)', () => {
// Inverse guard: a device whose anchor matches the current remote
// signature must NOT be dragged through a merge round-trip, which
// would cause the "everyone re-uploads on every startup" thrash seen
// in the pre-anchor implementation. This locks in that the anchor
// logic correctly short-circuits the common case.
const sig = 'v3:{"updatedAt":1700000000000}|len=80|sha256=' + 'a'.repeat(64);
const result = decideRemoteChanged({
currentSignature: sig,
currentResourceId: 'gist-1',
anchor: { signature: sig, resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, false);
assert.equal(result.reason, 'anchor-matches');
});