Compare commits

...
Author SHA1 Message Date
vorotamoroz e8bb05e884 Test ordinary mirror edits and preserve unknown local content 2026-09-18 01:14:35 +00:00
vorotamoroz c60323e11f Merge pull request #1201 from vrtmrz/fix/stale-file-revision-provenance
Prevent stale local files from replacing newer database content
2026-09-18 01:31:08 +09:00
3 changed files with 135 additions and 3 deletions
+60 -1
View File
@@ -7,6 +7,8 @@
# 3. DB-deleted file → NOT restored to storage (UPDATE STORAGE skip)
# 4. Both, storage newer → DB updated (SYNC: STORAGE → DB)
# 5. Both, DB newer → storage updated (SYNC: DB → STORAGE)
# 6. Compatibility mode → omitted vault-path works
# 7. Unknown local origin → conflict preserved, deduplicated, and resolved
#
# Not covered (require precise mtime control or artificial conflict injection):
# - Both, equal mtime → no-op (EVEN)
@@ -43,7 +45,8 @@ cli_test_init_settings_file "$SETTINGS_FILE"
# isConfigured=true is required for mirror (canProceedScan checks this)
cli_test_mark_settings_configured "$SETTINGS_FILE"
# Enable writeDocumentsIfConflicted to resolve unsynced conflicts during mirror
# Allow incoming DB content to be reflected when conflicts exist (Case 5).
# This does not resolve conflicts or authorise overwriting DB content.
node -e '
const fs = require("fs");
const file = process.argv[1];
@@ -181,6 +184,11 @@ echo "=== Case 4: storage newer → DB updated (Separated Paths) ==="
# Seed DB with old content (mtime ≈ now)
printf 'old content\n' | run_cli "$DB_DIR" --settings "$DB_SETTINGS" put test/sync-storage-newer.md
# Establish the file's recorded base before making an ordinary local edit.
# A direct put followed by unrelated local content has unknown provenance.
run_mirror_test
cli_test_assert_equal "old content" "$(cat "$VAULT_DIR/test/sync-storage-newer.md")" "Case 4 base was not reflected"
# Write new content to storage with a timestamp 1 hour in the future
printf 'new content\n' > "$VAULT_DIR/test/sync-storage-newer.md"
touch -t "$(portable_touch_timestamp '+1 hour')" "$VAULT_DIR/test/sync-storage-newer.md"
@@ -188,6 +196,8 @@ touch -t "$(portable_touch_timestamp '+1 hour')" "$VAULT_DIR/test/sync-storage-n
run_mirror_test
DB_RESULT_FILE="$WORK_DIR/case4-pull.txt"
CASE4_INFO="$(run_cli "$DB_DIR" --settings "$DB_SETTINGS" info test/sync-storage-newer.md)"
cli_test_assert_equal "N/A" "$(printf '%s' "$CASE4_INFO" | cli_test_json_string_field_from_stdin conflicts)" "Ordinary local edit unexpectedly created a conflict"
run_cli "$DB_DIR" --settings "$DB_SETTINGS" pull test/sync-storage-newer.md "$DB_RESULT_FILE"
if cmp -s "$VAULT_DIR/test/sync-storage-newer.md" "$DB_RESULT_FILE"; then
assert_pass "DB updated to match newer storage file"
@@ -238,6 +248,55 @@ else
assert_fail "Compatibility mode failed to sync file into DB"
fi
# ─────────────────────────────────────────────────────────────────────────────
# Case 7: Unknown local origin must preserve both contents, regardless of mtime
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Case 7: unknown local origin → preserve and resolve conflict ==="
UNKNOWN_PATH="test/unknown-origin.md"
printf 'original DB content\n' | run_cli "$DB_DIR" --settings "$DB_SETTINGS" put "$UNKNOWN_PATH"
printf 'unrelated local content\n' > "$VAULT_DIR/$UNKNOWN_PATH"
touch -t "$(portable_touch_timestamp '+1 hour')" "$VAULT_DIR/$UNKNOWN_PATH"
run_mirror_test
UNKNOWN_INFO="$(run_cli "$DB_DIR" --settings "$DB_SETTINGS" info "$UNKNOWN_PATH")"
WINNER="$(printf '%s' "$UNKNOWN_INFO" | cli_test_json_string_field_from_stdin revision)"
CONFLICT="$(printf '%s' "$UNKNOWN_INFO" | cli_test_json_string_field_from_stdin conflicts)"
if [[ ! "$WINNER" =~ ^1-[[:xdigit:]]+$ || ! "$CONFLICT" =~ ^1-[[:xdigit:]]+$ || "$WINNER" == "$CONFLICT" ]]; then
echo "[FAIL] Expected two independent non-deleted root revisions: $UNKNOWN_INFO" >&2
exit 1
fi
# Do not assume which randomly identified root PouchDB selects as the winner.
LOCAL_REV=""
DB_REV=""
for revision in "$WINNER" "$CONFLICT"; do
CONTENT="$(run_cli "$DB_DIR" --settings "$DB_SETTINGS" cat-rev "$UNKNOWN_PATH" "$revision" | cli_test_sanitise_cat_stdout)"
case "$CONTENT" in
'unrelated local content') LOCAL_REV="$revision" ;;
'original DB content') DB_REV="$revision" ;;
*) echo "[FAIL] Unexpected content for $revision: $CONTENT" >&2; exit 1 ;;
esac
done
[[ -n "$LOCAL_REV" && -n "$DB_REV" ]] || { echo "[FAIL] Both contents must remain readable" >&2; exit 1; }
# Force another ordinary save of the same unknown bytes, even if incoming
# reflection replaced the file under writeDocumentsIfConflicted.
printf 'unrelated local content\n' > "$VAULT_DIR/$UNKNOWN_PATH"
touch -t "$(portable_touch_timestamp '+1 hour')" "$VAULT_DIR/$UNKNOWN_PATH"
run_mirror_test
REPEATED_INFO="$(run_cli "$DB_DIR" --settings "$DB_SETTINGS" info "$UNKNOWN_PATH")"
cli_test_assert_equal "$WINNER" "$(printf '%s' "$REPEATED_INFO" | cli_test_json_string_field_from_stdin revision)" "Repeated mirror changed the winning revision"
cli_test_assert_equal "$CONFLICT" "$(printf '%s' "$REPEATED_INFO" | cli_test_json_string_field_from_stdin conflicts)" "Repeated mirror created another conflict"
run_cli "$DB_DIR" --vault "$VAULT_DIR" --settings "$DB_SETTINGS" resolve "$UNKNOWN_PATH" "$LOCAL_REV"
RESOLVED_INFO="$(run_cli "$DB_DIR" --settings "$DB_SETTINGS" info "$UNKNOWN_PATH")"
cli_test_assert_equal "N/A" "$(printf '%s' "$RESOLVED_INFO" | cli_test_json_string_field_from_stdin conflicts)" "CLI resolve left a conflict"
cli_test_assert_equal "$LOCAL_REV" "$(printf '%s' "$RESOLVED_INFO" | cli_test_json_string_field_from_stdin revision)" "CLI resolve selected the wrong revision"
cli_test_assert_equal "unrelated local content" "$(cat "$VAULT_DIR/$UNKNOWN_PATH")" "CLI resolve did not reflect the selected content"
assert_pass "Unknown local content was preserved, deduplicated, and resolved through the CLI"
# ─────────────────────────────────────────────────────────────────────────────
# Summary
# ─────────────────────────────────────────────────────────────────────────────
+50 -2
View File
@@ -11,6 +11,7 @@
* 4. Both, storage newer -> DB updated (SYNC: STORAGE -> DB)
* 5. Both, DB newer -> storage updated (SYNC: DB -> STORAGE)
* 6. Compatibility mode -> omitted vault-path works (same DB + vault path)
* 7. Unknown local origin -> conflict preserved, deduplicated, and resolved
*
* No external services are required.
*
@@ -18,9 +19,9 @@
* deno test -A test-mirror.ts
*/
import { assert } from "@std/assert";
import { assert, assertEquals } from "@std/assert";
import { TempDir } from "./helpers/temp.ts";
import { runCliOrFail } from "./helpers/cli.ts";
import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
import { initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts";
Deno.test("mirror: storage <-> DB synchronisation", async (t) => {
@@ -130,6 +131,10 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => {
await Deno.writeTextFile(seedFile, "old content\n");
await dbRun("push", seedFile, "test/sync-storage-newer.md");
// Reflect the shared base into the actual Vault before editing it.
await runMirror();
assertEquals(await Deno.readTextFile(workDir.join("vault", "test", "sync-storage-newer.md")), "old content\n");
// Write new content to storage with a timestamp 1 hour in the future
const storageFile = workDir.join("vault", "test", "sync-storage-newer.md");
await Deno.writeTextFile(storageFile, "new content\n");
@@ -138,6 +143,8 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => {
await runMirror();
const resultFile = workDir.join("case4-pull.txt");
const info = JSON.parse(await dbRun("info", "test/sync-storage-newer.md"));
assertEquals(info.conflicts, "N/A", "An ordinary local edit must not create a conflict");
await dbRun("pull", "test/sync-storage-newer.md", resultFile);
const storageContent = await Deno.readTextFile(storageFile);
const pulledContent = await Deno.readTextFile(resultFile);
@@ -184,6 +191,47 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => {
assert(pulled === "compat-content\n", `Compatibility mode failed to sync file into DB (got: '${pulled}')`);
console.log("[PASS] case 6: compatibility mode works");
});
// -------------------------------------------------------------------
// Case 7: unknown local origin must preserve both contents regardless of mtime.
// This deliberately uses put: push would record a file provenance entry.
// -------------------------------------------------------------------
await t.step("case 7: unknown local content is preserved, deduplicated, and resolved", async () => {
const path = "test/unknown-origin.md";
const storageFile = workDir.join("vault", "test", "unknown-origin.md");
await runCliWithInputOrFail("original DB content\n", dbDir, "--settings", dbSettings, "put", path);
const writeUnknownFile = async () => {
await Deno.writeTextFile(storageFile, "unrelated local content\n");
await Deno.utime(storageFile, new Date(), new Date(Date.now() + 3600_000));
};
await writeUnknownFile();
await runMirror();
const info = JSON.parse(await dbRun("info", path));
assert(/^1-[\da-f]+$/.test(info.revision), "Expected an independent winning root");
assert(/^1-[\da-f]+$/.test(info.conflicts), "Expected exactly one independent conflicting root");
assert(info.revision !== info.conflicts, "Expected two distinct revisions");
const contents = new Map<string, string>();
for (const revision of [info.revision, info.conflicts]) {
contents.set(await dbRun("cat-rev", path, revision), revision);
}
assertEquals([...contents.keys()].sort(), ["original DB content\n", "unrelated local content\n"]);
// Re-submit identical local bytes even if incoming reflection replaced
// the file under writeDocumentsIfConflicted; no third branch is needed.
await writeUnknownFile();
await runMirror();
const repeated = JSON.parse(await dbRun("info", path));
assertEquals(repeated.revision, info.revision);
assertEquals(repeated.conflicts, info.conflicts);
const localRevision = contents.get("unrelated local content\n")!;
await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", dbSettings, "resolve", path, localRevision);
const resolved = JSON.parse(await dbRun("info", path));
assertEquals(resolved.conflicts, "N/A");
assertEquals(resolved.revision, localRevision);
assertEquals(await Deno.readTextFile(storageFile), "unrelated local content\n");
});
});
// ---------------------------------------------------------------------------
+25
View File
@@ -52,6 +52,31 @@ After pushing, confirm that the `Unit Tests` job passed for the exact commit bei
The local checks and CI use the project's installed rule versions and configured file scope. Record the authenticated external Community Review result separately when that review is required; the project-side checks do not replace it. When the external review reports additional findings, retain its relevant output and investigate differences in installation, type resolution, scope, or rules.
## CLI mirror regression tests
Run the native CLI subprocess suite after building the CLI:
```bash
NODE_OPTIONS=--max-old-space-size=3072 npm run build --workspace self-hosted-livesync-cli
cd src/apps/cli/testdeno
deno test -A --no-check test-mirror.ts
```
The expected result is seven passing steps. These cover storage-only and database-only files, database deletion, an ordinary local edit, incoming database content, an omitted Vault path, and local content with unknown provenance.
For an ordinary local edit, first reflect the database content into the Vault, confirm its bytes, and then edit that file. The next `mirror` must store the edit without a conflict. For unknown provenance, use `put` to seed only the database and independently create different local content with a newer modification time. The next `mirror` must preserve two independent non-deleted revisions. Read both with `cat-rev`, submit the same local bytes again to check that no additional revision appears, and use `resolve` to select the local content. Confirm that the conflict is gone and the selected content is reflected into the Vault.
`put` deliberately bypasses file provenance, whereas `push` records it. Substituting one for the other changes the scenario. The tests enable `writeDocumentsIfConflicted` for incoming reflection; this setting does not authorise an ordinary save to replace unrelated database content or resolve the conflict. Do not assume which independent root PouchDB selects as the winner.
The [CLI Docker workflow](../.github/workflows/cli-docker.yml) runs the corresponding Bash suite. From the repository root, build and check that path with:
```bash
NODE_OPTIONS=--max-old-space-size=3072 npm run build:docker --workspace self-hosted-livesync-cli
npm run test:e2e:docker:mirror --workspace self-hosted-livesync-cli
```
The expected result is `PASS=7 FAIL=0`. These mirror suites use temporary local databases and need no CouchDB service. The complete `test:e2e:docker:all` command also runs the other Docker CLI suites and manages a disposable CouchDB fixture; use it to check the full Docker CI gate. Keep the ordinary-edit and unknown-provenance scenarios aligned between the Deno and Bash suites.
## Multiple-device conflict regression tests
### Preparation and execution