feat(git-sync): двусторонний Docmost↔git синк на унифицированном конвертере (#359)
Схлопнутая дельта feat/git-sync-2 поверх актуального develop (3085ec1b). Почему squash, а не буквальный rebase: в ветке 113 коммитов, из которых develop уже впитал ~80 в курированном виде при унификации конвертера (#326/#293) — но не patch-identical, поэтому они не отваливаются сами; а коммит, УДАЛЯЮЩИЙ вендоренную копию конвертера, апстримный и в replay-набор не входит, так что наивный replay заново добавил бы вендоренные копии. Поэтому корректный итог — «develop + чистая net-дельта ветки», сведённая 3-way мержом (merge-base5336f06d). Net-дельта: серверный git-sync модуль (GitSyncModule/orchestrator/HTTP), движок git-sync (layout/reconcile/pull/push/stabilize + QA), два фикса round-trip/data-loss конвертера поверх УНИФИЦИРОВАННОГО @docmost/prosemirror-markdown (вендоренной копии больше нет — git-sync целиком на унифицированном конвертере, поведение унифицированного принято за эталон), e2e-скрипты, доки. Конфликты (4) слиты объединением: main.ts (метрики + GitHttpService), apps/server/package.json (pretest-суперсет; moduleNameMapper: git-sync→src и .js-strip оставлены, а bare-specifier @docmost/prosemirror-markdown→src УБРАН — серверный jest выровнен на develop-подход #345 со сборённым пакетом), .env.example (метрики + GIT_SYNC блоки), AGENTS.md (строки про пакеты; устаревшая заметка про «три hand-synced копии схемы» в git-sync поправлена — схема теперь только в @docmost/prosemirror-markdown). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* git-sync BROWSER e2e — drives the real Docmost web UI with Playwright to
|
||||
* reproduce the exact user flow that previously caused data loss: pages created
|
||||
* in the browser start UNTITLED (all collapse to the `_` vault filename); typing
|
||||
* a title reshuffles that collision and used to TRASH another live page. This
|
||||
* test creates several pages via the UI, titles one, runs a sync, and asserts
|
||||
* NOTHING was moved to Trash.
|
||||
*
|
||||
* Setup: needs Playwright + a Chromium build. The project should add
|
||||
* `@playwright/test` as a devDep (`pnpm dlx playwright install chromium`). This
|
||||
* script resolves playwright-core + the chromium binary from env so it can run
|
||||
* against an already-installed browser:
|
||||
* PW_CORE=/path/to/node_modules/playwright-core
|
||||
* PW_CHROME=/path/to/chrome
|
||||
* and the live stand env (SERVER/SPACE_ID/EMAIL/PASSWORD/DB_CONTAINER) like the
|
||||
* shell e2e suites.
|
||||
*/
|
||||
const { execSync } = require('node:child_process');
|
||||
|
||||
const SERVER = process.env.SERVER || 'http://localhost:3000';
|
||||
const WEB = process.env.WEB || 'http://localhost:5173';
|
||||
const SPACE_ID = process.env.SPACE_ID || '019ef1f7-437b-7ae9-9306-809a1729f085';
|
||||
const SPACE_SLUG = process.env.SPACE_SLUG || 'general';
|
||||
const EMAIL = process.env.EMAIL || 'admin@test.local';
|
||||
const PASSWORD = process.env.PASSWORD || 'Test12345!';
|
||||
const DB = process.env.DB_CONTAINER || 'gitmost-db';
|
||||
const PW_CORE = process.env.PW_CORE || '/home/claude/pw/node_modules/playwright-core';
|
||||
const PW_CHROME = process.env.PW_CHROME ||
|
||||
'/home/claude/.cache/ms-playwright/chromium-1148/chrome-linux/chrome';
|
||||
|
||||
const { chromium } = require(PW_CORE);
|
||||
const psql = (q) =>
|
||||
execSync(`docker exec ${DB} psql -U docmost -d docmost -tAc "${q}"`, { encoding: 'utf8' }).trim();
|
||||
const trashedCount = () =>
|
||||
Number(psql(`select count(*) from pages where space_id='${SPACE_ID}' and deleted_at is not null`));
|
||||
let cookie = '';
|
||||
const login = () => {
|
||||
const out = execSync(
|
||||
`curl -s -i -X POST ${SERVER}/api/auth/login -H 'Content-Type: application/json' -d '{"email":"${EMAIL}","password":"${PASSWORD}"}'`,
|
||||
{ encoding: 'utf8' });
|
||||
cookie = (out.match(/authToken=([^;]+)/) || [])[1] || '';
|
||||
};
|
||||
const sync = () => execSync(
|
||||
`curl -s -b 'authToken=${cookie}' -X POST ${SERVER}/api/git-sync/trigger -H 'Content-Type: application/json' -d '{"spaceId":"${SPACE_ID}"}'`,
|
||||
{ encoding: 'utf8' });
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (m) => { console.log(' \x1b[32mPASS\x1b[0m ' + m); pass++; };
|
||||
const bad = (m) => { console.log(' \x1b[31mFAIL\x1b[0m ' + m); fail++; };
|
||||
|
||||
(async () => {
|
||||
login();
|
||||
const trashBefore = trashedCount();
|
||||
const browser = await chromium.launch({ executablePath: PW_CHROME, args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
// --- log in through the UI ---
|
||||
await page.goto(`${WEB}/login`, { waitUntil: 'networkidle' });
|
||||
await page.getByPlaceholder('email@example.com').fill(EMAIL);
|
||||
await page.getByPlaceholder(/password/i).fill(PASSWORD);
|
||||
await page.getByRole('button', { name: /sign in|log in|login|войти/i }).click();
|
||||
await page.waitForTimeout(2000);
|
||||
ok('logged in via the browser');
|
||||
|
||||
// --- create several UNTITLED pages via the UI (the bug trigger) ---
|
||||
await page.goto(`${WEB}/s/${SPACE_SLUG}`, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(1200);
|
||||
const createdUrls = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.getByRole('button', { name: 'Create page' }).first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
createdUrls.push(page.url());
|
||||
sync(); // each create fires a real git-sync cycle
|
||||
}
|
||||
ok('created 3 untitled pages through the UI');
|
||||
|
||||
// --- type a title into the page currently open (retitle == the trigger) ---
|
||||
const titleEditor = page.locator('.tiptap.ProseMirror').first();
|
||||
await titleEditor.click();
|
||||
await page.keyboard.type('Заголовок через браузер');
|
||||
await page.waitForTimeout(1500); // debounced save
|
||||
sync(); sync();
|
||||
ok('typed a title into one page');
|
||||
|
||||
// --- THE assertion: nothing got trashed by the reshuffle ---
|
||||
const trashAfter = trashedCount();
|
||||
if (trashAfter === trashBefore) ok(`no page trashed by the untitled+retitle flow (trash stayed ${trashBefore})`);
|
||||
else bad(`a page was TRASHED by the browser flow (trash ${trashBefore} -> ${trashAfter}) — DATA LOSS`);
|
||||
|
||||
// the titled page must still be live
|
||||
const titled = Number(psql(`select count(*) from pages where space_id='${SPACE_ID}' and title='Заголовок через браузер' and deleted_at is null`));
|
||||
if (titled === 1) ok('the titled page is live'); else bad('the titled page is not live');
|
||||
} finally {
|
||||
await browser.close();
|
||||
// cleanup: hard-delete the pages this run created (titled + the untitled ones from this run)
|
||||
psql(`delete from pages where space_id='${SPACE_ID}' and (title='Заголовок через браузер' or (title='' and created_at > now() - interval '5 minutes'))`);
|
||||
sync();
|
||||
}
|
||||
console.log(`\nRESULTS: ${pass} passed, ${fail} failed`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
})().catch((e) => { console.error(e); process.exit(2); });
|
||||
Executable
+231
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# git-sync ADVANCED end-to-end suite — authz, protocol hardening, concurrency,
|
||||
# and structural sync (rename / reparent / delete-cap), driven against a LIVE
|
||||
# stand. Companion to git-sync-e2e.sh (the basic two-way flows). These cases
|
||||
# need deeper hooks than a plain clone:
|
||||
# - the vault working repo on the host ($VAULT_DIR/<spaceId>) for ref/SHA asserts,
|
||||
# - the Redis container ($REDIS_CONTAINER) to inject a held lock (503 path),
|
||||
# - DB-created fixture users / a second space (auto-created + torn down).
|
||||
#
|
||||
# Came out of a generate->critique subagent pass on "what is NOT covered". The
|
||||
# critic verified the contracts against the code (e.g. a non-member of an
|
||||
# ENABLED space gets 403, not 404 — only a missing / sync-disabled space 404s).
|
||||
#
|
||||
# Usage: apps/server/test/git-sync-e2e-advanced.sh
|
||||
set -uo pipefail
|
||||
|
||||
SERVER="${SERVER:-http://localhost:3000}"
|
||||
# By default the suite PROVISIONS its own throwaway space (never touches real
|
||||
# data). Set SPACE_ID explicitly to run against an existing space instead.
|
||||
SPACE_ID="${SPACE_ID:-}"
|
||||
EMAIL="${EMAIL:-admin@test.local}"
|
||||
PASSWORD="${PASSWORD:-Test12345!}"
|
||||
DB_CONTAINER="${DB_CONTAINER:-gitmost-db}"
|
||||
DB_USER="${DB_USER:-docmost}"
|
||||
DB_NAME="${DB_NAME:-docmost}"
|
||||
REDIS_CONTAINER="${REDIS_CONTAINER:-gitmost-redis}"
|
||||
VAULT_DIR="${VAULT_DIR:-/tmp/gitmost-vaults}"
|
||||
LOCK_PREFIX="git-sync:lock:"
|
||||
|
||||
BASIC=$(printf '%s:%s' "$EMAIL" "$PASSWORD" | base64 -w0)
|
||||
GIT_URL="" # set once the space is known (after login/provisioning)
|
||||
VAULT="" # ditto
|
||||
PROVISIONED="" # the space id we created (and must delete on exit), if any
|
||||
WORK=$(mktemp -d /tmp/git-sync-adv.XXXXXX)
|
||||
COOKIES="$WORK/cookies.txt"
|
||||
PASS=0; FAIL=0
|
||||
READER_ID=""; OUTSIDER_ID=""; SPACE2_ID=""
|
||||
|
||||
say(){ printf '\n\033[1m== %s\033[0m\n' "$*"; }
|
||||
ok(){ printf ' \033[32mPASS\033[0m %s\n' "$*"; PASS=$((PASS+1)); }
|
||||
bad(){ printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAIL=$((FAIL+1)); }
|
||||
psqlq(){ docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tAc "$1" 2>/dev/null | tr -d '[:space:]'; }
|
||||
api(){ curl -s -b "$COOKIES" "$@"; }
|
||||
gitc(){ git -c http.extraHeader="Authorization: Basic $BASIC" "$@"; }
|
||||
code(){ curl -s -o /dev/null -w '%{http_code}' "$@"; } # print HTTP status
|
||||
basicfor(){ printf '%s:%s' "$1" "$PASSWORD" | base64 -w0; }
|
||||
sync_now(){ api -X POST "$SERVER/api/git-sync/trigger" -H 'Content-Type: application/json' -d "{\"spaceId\":\"$SPACE_ID\"}" >/dev/null; }
|
||||
vault_sha(){ git -C "$VAULT" rev-parse "$1" 2>/dev/null; }
|
||||
# Push retrying on 503 — the smart-HTTP host returns 503+Retry-After when a sync
|
||||
# cycle holds the lock (a real git client retries; so do we, to dodge poll races).
|
||||
gpush(){ local out; for _ in $(seq 1 6); do out=$(gitc push origin main 2>&1); echo "$out" | grep -q '503\|busy' && { sleep 2; continue; }; return 0; done; return 1; }
|
||||
|
||||
teardown(){
|
||||
# Hard-delete fixtures by EMAIL/NAME pattern (robust against a mid-run abort
|
||||
# that never captured an id), so the stand + the basic suite stay clean.
|
||||
psqlq "delete from space_members where user_id in (select id from users where email like 'e2e-adv-%@test.local');
|
||||
delete from users where email like 'e2e-adv-%@test.local';
|
||||
delete from spaces where name like 'E2E-ADV-%';
|
||||
delete from pages where space_id='$SPACE_ID' and title like 'E2E-ADV-%';" >/dev/null
|
||||
docker exec "$REDIS_CONTAINER" redis-cli del "${LOCK_PREFIX}${SPACE_ID}" >/dev/null 2>&1
|
||||
# Delete the throwaway space we created (cascades pages); the delete-cap case
|
||||
# leaves the vault non-convergent, so dropping the whole space + its vault is
|
||||
# the clean teardown. (When run against a caller-supplied space, only reset the
|
||||
# vault — the fixtures above were already removed by pattern.)
|
||||
if [ -n "$PROVISIONED" ]; then
|
||||
psqlq "delete from pages where space_id='$PROVISIONED'; delete from spaces where id='$PROVISIONED';" >/dev/null
|
||||
fi
|
||||
[ -n "$VAULT" ] && rm -rf "$VAULT"
|
||||
[ -z "$PROVISIONED" ] && [ -n "$SPACE_ID" ] && sync_now
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap teardown EXIT
|
||||
|
||||
# Create a workspace user that shares the admin's password hash (so it logs in
|
||||
# with $PASSWORD). $2 = "reader" adds a reader space membership; "none" = no
|
||||
# membership (non-member). Echoes the new user id.
|
||||
make_user(){
|
||||
local email="$1" role="$2" uid
|
||||
# grep the bare uuid out of the RETURNING output (psql may append a status tag).
|
||||
uid=$(docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tAc \
|
||||
"insert into users (id,email,name,password,workspace_id,created_at,updated_at,has_generated_password,is_agent)
|
||||
select gen_random_uuid(),'$email','$email',password,workspace_id,now(),now(),false,false
|
||||
from users where email='$EMAIL' returning id;" 2>/dev/null \
|
||||
| grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)
|
||||
if [ "$role" = "reader" ]; then
|
||||
psqlq "insert into space_members (id,user_id,space_id,role,added_by_id,created_at,updated_at)
|
||||
values (gen_random_uuid(),'$uid','$SPACE_ID','reader','$uid',now(),now());" >/dev/null
|
||||
fi
|
||||
printf '%s' "$uid"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "setup: login + fixtures"
|
||||
[ "$(code -c "$COOKIES" -X POST "$SERVER/api/auth/login" -H 'Content-Type: application/json' -d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}")" = "200" ] \
|
||||
&& ok "admin login" || { bad "admin login failed"; exit 1; }
|
||||
if [ -z "$SPACE_ID" ]; then
|
||||
slug="adv$(date +%s)$RANDOM"
|
||||
SPACE_ID=$(api -X POST "$SERVER/api/spaces/create" -H 'Content-Type: application/json' \
|
||||
-d "{\"name\":\"E2E-ADV Throwaway $slug\",\"slug\":\"$slug\"}" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
[ -n "$SPACE_ID" ] || { bad "could not provision a test space"; exit 1; }
|
||||
PROVISIONED="$SPACE_ID"
|
||||
psqlq "update spaces set settings = coalesce(settings,'{}'::jsonb) || '{\"gitSync\":{\"enabled\":true}}'::jsonb where id='$SPACE_ID';" >/dev/null
|
||||
ok "provisioned throwaway space $SPACE_ID"
|
||||
fi
|
||||
GIT_URL="$SERVER/git/$SPACE_ID.git"
|
||||
VAULT="$VAULT_DIR/$SPACE_ID"
|
||||
sync_now # initialize the vault for the new space
|
||||
gitc clone -q "$GIT_URL" "$WORK/c" 2>/dev/null && ok "baseline clone" || { bad "baseline clone failed"; exit 1; }
|
||||
( cd "$WORK/c" && git config user.email e2e@test && git config user.name e2e )
|
||||
|
||||
# ===========================================================================
|
||||
say "protocol: unparseable / wrong-method requests are rejected (never reach git)"
|
||||
# A recognized git content-type to an UNKNOWN service subpath reaches the handler
|
||||
# and is rejected as a bad request (resolveServiceKind -> null -> 400).
|
||||
[ "$(code -X POST -H "Authorization: Basic $BASIC" -H 'Content-Type: application/x-git-upload-pack-request' "$GIT_URL/git-bogus-pack")" = "400" ] \
|
||||
&& ok "unknown service subpath -> 400" || bad "unknown service subpath not 400"
|
||||
# An UNKNOWN content-type is rejected by the global content-type allowlist (415)
|
||||
# before the git handler even runs — also a valid rejection.
|
||||
[ "$(code -X POST -H "Authorization: Basic $BASIC" -H 'Content-Type: application/x-git-bogus' "$GIT_URL/git-receive-pack")" = "415" ] \
|
||||
&& ok "unknown content-type -> 415 (global allowlist)" || bad "unknown content-type not 415"
|
||||
[ "$(code -X PUT -H "Authorization: Basic $BASIC" "$GIT_URL/git-receive-pack")" = "400" ] \
|
||||
&& ok "PUT on a pack endpoint -> 400" || bad "PUT not 400"
|
||||
[ "$(code -X DELETE -H "Authorization: Basic $BASIC" "$GIT_URL/info/refs?service=git-upload-pack")" = "400" ] \
|
||||
&& ok "DELETE on info/refs -> 400" || bad "DELETE not 400"
|
||||
|
||||
# ===========================================================================
|
||||
say "protocol: path-traversal in space-id / subpath is rejected (no escape)"
|
||||
for u in \
|
||||
"$SERVER/git/..%2f..%2f..%2fetc.git/info/refs?service=git-upload-pack" \
|
||||
"$GIT_URL/%2e%2e%2finfo/refs?service=git-upload-pack" \
|
||||
"$SERVER/git/.git/info/refs?service=git-upload-pack" ; do
|
||||
c=$(curl -s --path-as-is -o /dev/null -w '%{http_code}' -H "Authorization: Basic $BASIC" "$u")
|
||||
case "$c" in 400|404) ok "traversal '${u##*/git/}' -> $c";; *) bad "traversal '${u##*/git/}' got $c (expected 400/404)";; esac
|
||||
done
|
||||
|
||||
# ===========================================================================
|
||||
say "authz: a sync-DISABLED space is 404 (existence not revealed), not 403"
|
||||
SPACE2_ID=$(api -X POST "$SERVER/api/spaces/create" -H 'Content-Type: application/json' -d '{"name":"E2E-ADV-Space2","slug":"e2eadvspace2"}' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
if [ -n "$SPACE2_ID" ]; then
|
||||
[ "$(code -H "Authorization: Basic $BASIC" "$SERVER/git/$SPACE2_ID.git/info/refs?service=git-upload-pack")" = "404" ] \
|
||||
&& ok "admin member of a gitSync-disabled space -> 404" || bad "disabled space did not 404"
|
||||
# enabling it flips to 200 (proves the per-space flag is the gate)
|
||||
psqlq "update spaces set settings = coalesce(settings,'{}'::jsonb) || '{\"gitSync\":{\"enabled\":true}}'::jsonb where id='$SPACE2_ID';" >/dev/null
|
||||
[ "$(code -H "Authorization: Basic $BASIC" "$SERVER/git/$SPACE2_ID.git/info/refs?service=git-upload-pack")" = "200" ] \
|
||||
&& ok "flipping gitSync.enabled=true -> 200" || bad "enabled 2nd space did not 200"
|
||||
else
|
||||
bad "could not create a 2nd space"
|
||||
fi
|
||||
|
||||
# ===========================================================================
|
||||
say "authz: reader can FETCH (200) but is FORBIDDEN to push (403)"
|
||||
READER_ID=$(make_user "e2e-adv-reader@test.local" reader)
|
||||
RBASIC=$(basicfor "e2e-adv-reader@test.local")
|
||||
[ "$(code -H "Authorization: Basic $RBASIC" "$GIT_URL/info/refs?service=git-upload-pack")" = "200" ] \
|
||||
&& ok "reader fetch -> 200" || bad "reader fetch not 200"
|
||||
[ "$(code -H "Authorization: Basic $RBASIC" "$GIT_URL/info/refs?service=git-receive-pack")" = "403" ] \
|
||||
&& ok "reader push (receive-pack) -> 403" || bad "reader push not 403"
|
||||
|
||||
# ===========================================================================
|
||||
say "authz: a NON-member of an enabled space -> 404 (NOT 403; existence not leaked)"
|
||||
OUTSIDER_ID=$(make_user "e2e-adv-outsider@test.local" none)
|
||||
OBASIC=$(basicfor "e2e-adv-outsider@test.local")
|
||||
c=$(code -H "Authorization: Basic $OBASIC" "$GIT_URL/info/refs?service=git-upload-pack")
|
||||
# The gate deliberately returns 404 (not 403) for a non-member so the 403<->404
|
||||
# split cannot be used to probe which private spaces exist (git-http.helpers.spec.ts).
|
||||
[ "$c" = "404" ] && ok "non-member fetch -> 404 (space existence not leaked)" || bad "non-member got $c (contract is 404)"
|
||||
|
||||
# ===========================================================================
|
||||
say "concurrency: a push while the per-space lock is held -> 503 + Retry-After"
|
||||
docker exec "$REDIS_CONTAINER" redis-cli set "${LOCK_PREFIX}${SPACE_ID}" "held-by-test" PX 8000 NX >/dev/null 2>&1
|
||||
hdr=$(curl -s -D - -o /dev/null -X POST -H "Authorization: Basic $BASIC" \
|
||||
-H 'Content-Type: application/x-git-receive-pack-request' --data-binary '0000' \
|
||||
"$GIT_URL/git-receive-pack")
|
||||
st=$(printf '%s' "$hdr" | head -1 | grep -o '[0-9]\{3\}')
|
||||
ra=$(printf '%s' "$hdr" | grep -i '^Retry-After:' | tr -d '\r')
|
||||
main_before=$(vault_sha main)
|
||||
[ "$st" = "503" ] && ok "push during held lock -> 503" || bad "lock-held push got $st (expected 503)"
|
||||
[ -n "$ra" ] && ok "503 carries a $ra header" || bad "503 missing Retry-After header"
|
||||
docker exec "$REDIS_CONTAINER" redis-cli del "${LOCK_PREFIX}${SPACE_ID}" >/dev/null 2>&1
|
||||
[ "$(vault_sha main)" = "$main_before" ] && ok "receive-pack did not mutate the vault while locked" || bad "vault main changed under a held lock"
|
||||
|
||||
# ===========================================================================
|
||||
say "idempotent re-sync: nothing changes when nothing changed (no churn)"
|
||||
sync_now
|
||||
m1=$(vault_sha main); lp1=$(vault_sha refs/docmost/last-pushed)
|
||||
sync_now; sync_now
|
||||
m2=$(vault_sha main); lp2=$(vault_sha refs/docmost/last-pushed)
|
||||
[ "$m1" = "$m2" ] && [ "$lp1" = "$lp2" ] && ok "main + last-pushed SHAs stable across idle cycles" \
|
||||
|| bad "idle cycles churned refs (main $m1->$m2, last-pushed $lp1->$lp2)"
|
||||
|
||||
# (Structural rename/move on the live stand is deliberately NOT scripted here: a
|
||||
# freshly-API-created page has a meta-only body, so git's rename-similarity
|
||||
# heuristic classifies a `git mv` of it as delete+add rather than `R`, which is a
|
||||
# test-fixture artifact, not a feature bug. The rename/move classifier is covered
|
||||
# deterministically by the engine unit suite — packages/git-sync/test/
|
||||
# classify-rename-moves.test.ts and node-ops.test.ts.)
|
||||
|
||||
# NOTE (review #12): the former "delete cap" block was removed. There is NO
|
||||
# delete cap in the orchestrator ("There is no delete cap") and /status does not
|
||||
# expose maxDeletesPerCycle, so the block failed deterministically with a scary
|
||||
# "(data loss!)". The real contract — every git-rm'd page soft-deletes to Trash
|
||||
# (recoverable) — is exercised by the git-side-delete scenario elsewhere and by
|
||||
# the engine unit suite.
|
||||
|
||||
# ===========================================================================
|
||||
say "data-loss guard #2: untitled pages + retitle must NOT trash other pages"
|
||||
# THE bug from the browser flow: Docmost creates pages UNTITLED (title=''), which
|
||||
# all serialize to the `_` fallback name. Retitling one reshuffles the `_`
|
||||
# collision and relocates another's file; git reports the move as delete+add and
|
||||
# the push used to TRASH the relocated live page. Identity is the pageId now.
|
||||
ut_before=$(psqlq "select count(*) from pages where space_id='$SPACE_ID' and deleted_at is not null;")
|
||||
ut_ids=""
|
||||
for i in 1 2 3 4; do
|
||||
id=$(api -X POST "$SERVER/api/pages/create" -H 'Content-Type: application/json' -d "{\"spaceId\":\"$SPACE_ID\",\"title\":\"\"}" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
ut_ids="$ut_ids $id"; sync_now
|
||||
done
|
||||
# retitle the first one (like typing a title in the editor), then sync twice
|
||||
first=$(echo $ut_ids | awk '{print $1}')
|
||||
api -X POST "$SERVER/api/pages/update" -H 'Content-Type: application/json' -d "{\"pageId\":\"$first\",\"title\":\"E2E-ADV-Titled-$RANDOM\"}" >/dev/null
|
||||
sync_now; sync_now
|
||||
ut_after=$(psqlq "select count(*) from pages where space_id='$SPACE_ID' and deleted_at is not null;")
|
||||
live_kept=$(psqlq "select count(*) from pages where space_id='$SPACE_ID' and deleted_at is null and ($(echo $ut_ids | sed "s/ \?\([0-9a-f-]\+\)/ or id='\1'/g; s/^ or //"));")
|
||||
[ "${ut_after:-9}" = "${ut_before:-0}" ] && ok "no page trashed by the untitled+retitle reshuffle (was the data-loss bug)" || bad "trashed count grew ${ut_before}->${ut_after} (page lost to the reshuffle!)"
|
||||
[ "${live_kept:-0}" = "4" ] && ok "all 4 untitled/retitled pages still LIVE" || bad "only $live_kept/4 of the untitled pages survived"
|
||||
# cleanup these via the E2E-ADV teardown (the retitled one) + hard-delete the rest
|
||||
psqlq "delete from pages where id in ($(echo $ut_ids | sed "s/ \?\([0-9a-f-]\+\)/,'\1'/g; s/^,//"));" >/dev/null
|
||||
|
||||
# ===========================================================================
|
||||
say "RESULTS: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ] && exit 0 || exit 1
|
||||
Executable
+221
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# git-sync end-to-end test suite.
|
||||
#
|
||||
# Exercises the FULL two-way sync against a LIVE gitmost server over the real
|
||||
# smart-HTTP /git remote: clone (fetch), push (git -> Docmost), Docmost -> git,
|
||||
# delete -> trash, the 3-way body merge, and the auth/authz gate. This is the
|
||||
# integration counterpart to the unit suites — it boots nothing itself; it drives
|
||||
# a running stand.
|
||||
#
|
||||
# Prerequisites (a running git-sync stand):
|
||||
# - server up at $SERVER with GIT_SYNC_ENABLED=true + GIT_SYNC_HTTP_ENABLED=true
|
||||
# and a configured GIT_SYNC_SERVICE_USER_ID;
|
||||
# - a space whose settings.gitSync.enabled = true ($SPACE_ID);
|
||||
# - an admin user ($EMAIL/$PASSWORD) who is a member of that space;
|
||||
# - the Postgres container reachable for DB assertions ($DB_CONTAINER).
|
||||
#
|
||||
# Usage: apps/server/test/git-sync-e2e.sh
|
||||
# Override any of the env vars below to point at a different stand.
|
||||
set -uo pipefail
|
||||
|
||||
SERVER="${SERVER:-http://localhost:3000}"
|
||||
# By default the suite PROVISIONS its own throwaway space (so it never touches
|
||||
# real data). Set SPACE_ID explicitly to run against an existing space instead.
|
||||
SPACE_ID="${SPACE_ID:-}"
|
||||
EMAIL="${EMAIL:-admin@test.local}"
|
||||
PASSWORD="${PASSWORD:-Test12345!}"
|
||||
DB_CONTAINER="${DB_CONTAINER:-gitmost-db}"
|
||||
DB_USER="${DB_USER:-docmost}"
|
||||
DB_NAME="${DB_NAME:-docmost}"
|
||||
|
||||
BASIC=$(printf '%s:%s' "$EMAIL" "$PASSWORD" | base64 -w0)
|
||||
GIT_URL="" # set once the space is known (after login/provisioning)
|
||||
PROVISIONED="" # the space id we created (and must delete on exit), if any
|
||||
WORK=$(mktemp -d /tmp/git-sync-e2e.XXXXXX)
|
||||
COOKIES="$WORK/cookies.txt"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
cleanup() {
|
||||
# Delete the throwaway space we created (cascades its pages); never touch a
|
||||
# caller-supplied space beyond our own E2E-* fixtures.
|
||||
if [ -n "$PROVISIONED" ]; then
|
||||
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tAc \
|
||||
"delete from pages where space_id='$PROVISIONED'; delete from spaces where id='$PROVISIONED';" >/dev/null 2>&1
|
||||
rm -rf "/tmp/gitmost-vaults/$PROVISIONED" 2>/dev/null
|
||||
elif [ -n "$SPACE_ID" ]; then
|
||||
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tAc \
|
||||
"delete from pages where space_id='$SPACE_ID' and title like 'E2E-%';" >/dev/null 2>&1
|
||||
curl -s -b "$COOKIES" -X POST "$SERVER/api/git-sync/trigger" \
|
||||
-H 'Content-Type: application/json' -d "{\"spaceId\":\"$SPACE_ID\"}" >/dev/null 2>&1
|
||||
fi
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
say() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
|
||||
ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; PASS=$((PASS+1)); }
|
||||
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAIL=$((FAIL+1)); }
|
||||
|
||||
gitc() { git -c http.extraHeader="Authorization: Basic $BASIC" "$@"; }
|
||||
# Push retrying on 503 — the host returns 503+Retry-After when a sync cycle holds
|
||||
# the per-space lock (a real client retries; so do we, to dodge poll races).
|
||||
gpush() { local out; for _ in 1 2 3 4 5 6; do out=$(gitc push -q origin main 2>&1); echo "$out" | grep -q '503\|busy' && { sleep 2; continue; }; return 0; done; return 1; }
|
||||
psqlq() { docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tAc "$1" 2>/dev/null; }
|
||||
api() { curl -s -b "$COOKIES" "$@"; }
|
||||
|
||||
# Force one synchronous sync cycle and return when it has applied.
|
||||
sync_now() {
|
||||
api -X POST "$SERVER/api/git-sync/trigger" -H 'Content-Type: application/json' \
|
||||
-d "{\"spaceId\":\"$SPACE_ID\"}" >/dev/null
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
say "auth: login as the admin"
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -c "$COOKIES" -X POST \
|
||||
"$SERVER/api/auth/login" -H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}")
|
||||
[ "$code" = "200" ] && ok "login 200" || { bad "login returned $code"; exit 1; }
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
if [ -z "$SPACE_ID" ]; then
|
||||
say "setup: provision a throwaway git-sync space (never touches real data)"
|
||||
slug="e2e$(date +%s)$RANDOM"
|
||||
SPACE_ID=$(api -X POST "$SERVER/api/spaces/create" -H 'Content-Type: application/json' \
|
||||
-d "{\"name\":\"E2E Throwaway $slug\",\"slug\":\"$slug\"}" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
if [ -n "$SPACE_ID" ]; then
|
||||
PROVISIONED="$SPACE_ID"
|
||||
psqlq "update spaces set settings = coalesce(settings,'{}'::jsonb) || '{\"gitSync\":{\"enabled\":true}}'::jsonb where id='$SPACE_ID';" >/dev/null
|
||||
ok "provisioned space $SPACE_ID"
|
||||
else
|
||||
bad "could not provision a test space"; exit 1
|
||||
fi
|
||||
fi
|
||||
GIT_URL="$SERVER/git/$SPACE_ID.git"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
say "gate: smart-HTTP auth/authz"
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "$GIT_URL/info/refs?service=git-upload-pack")
|
||||
[ "$code" = "401" ] && ok "no credentials -> 401" || bad "no creds expected 401, got $code"
|
||||
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Basic $(printf '%s:wrong' "$EMAIL" | base64 -w0)" \
|
||||
"$GIT_URL/info/refs?service=git-upload-pack")
|
||||
[ "$code" = "401" ] && ok "wrong password -> 401" || bad "wrong creds expected 401, got $code"
|
||||
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Basic $BASIC" \
|
||||
"$SERVER/git/00000000-0000-0000-0000-000000000000.git/info/refs?service=git-upload-pack")
|
||||
[ "$code" = "404" ] && ok "unknown space -> 404 (existence not revealed)" || bad "unknown space expected 404, got $code"
|
||||
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Basic $BASIC" \
|
||||
"$GIT_URL/info/refs?service=git-upload-pack")
|
||||
[ "$code" = "200" ] && ok "valid creds + sync space -> 200" || bad "valid clone gate expected 200, got $code"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# A DEDICATED test page so the push/merge edits never touch a real page, and so
|
||||
# a freshly-provisioned (empty) space has content for the fetch test below.
|
||||
say "setup: create a dedicated test page (edits target only this one)"
|
||||
TEST_TITLE="E2E-SyncTarget-$RANDOM$RANDOM"
|
||||
TEST_ID=$(api -X POST "$SERVER/api/pages/create" -H 'Content-Type: application/json' \
|
||||
-d "{\"spaceId\":\"$SPACE_ID\",\"title\":\"$TEST_TITLE\"}" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
[ -n "$TEST_ID" ] && ok "created test page $TEST_TITLE" || { bad "could not create the test page"; }
|
||||
sync_now
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
say "fetch: clone the space vault over HTTP"
|
||||
sync_now
|
||||
if gitc clone -q "$GIT_URL" "$WORK/clone" 2>/dev/null; then
|
||||
count=$(find "$WORK/clone" -maxdepth 1 -name '*.md' | wc -l)
|
||||
[ "$count" -ge 1 ] && ok "clone succeeded with $count markdown file(s)" || bad "clone has no .md files"
|
||||
else
|
||||
bad "clone failed"
|
||||
fi
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
say "push: a git edit propagates into the (dedicated) Docmost page"
|
||||
rm -rf "$WORK/cpush"; gitc clone -q "$GIT_URL" "$WORK/cpush" 2>/dev/null
|
||||
cd "$WORK/cpush" || exit 1
|
||||
git config user.email e2e@test >/dev/null; git config user.name e2e >/dev/null
|
||||
target=$(grep -rl "$TEST_ID" --include='*.md' . | head -1)
|
||||
if [ -n "$target" ]; then
|
||||
MARK="E2E-PUSH-$RANDOM$RANDOM"
|
||||
printf '\n## %s\n' "$MARK" >> "$target"
|
||||
git commit -aqm "e2e push: $MARK"
|
||||
if gpush; then
|
||||
sleep 2
|
||||
has=$(psqlq "select count(*) from pages where id='$TEST_ID' and content::text like '%$MARK%';")
|
||||
[ "${has:-0}" -ge 1 ] && ok "pushed edit reached the test page" || bad "marker $MARK not in the test page content"
|
||||
else
|
||||
bad "git push failed"
|
||||
fi
|
||||
else
|
||||
bad "test page .md not found in the clone"
|
||||
fi
|
||||
cd "$WORK" || exit 1
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
say "Docmost -> git: a page created in Docmost appears in the vault"
|
||||
NEW_TITLE="E2E-Created-$RANDOM"
|
||||
new_id=$(api -X POST "$SERVER/api/pages/create" -H 'Content-Type: application/json' \
|
||||
-d "{\"spaceId\":\"$SPACE_ID\",\"title\":\"$NEW_TITLE\"}" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
if [ -n "$new_id" ]; then
|
||||
sync_now
|
||||
rm -rf "$WORK/clone2"
|
||||
gitc clone -q "$GIT_URL" "$WORK/clone2" 2>/dev/null
|
||||
if find "$WORK/clone2" -name "*$NEW_TITLE*.md" | grep -q .; then
|
||||
ok "new Docmost page '$NEW_TITLE' materialized as a vault file"
|
||||
else
|
||||
bad "created page '$NEW_TITLE' did not appear in the vault"
|
||||
fi
|
||||
else
|
||||
bad "could not create a page via the API"
|
||||
fi
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
say "delete: removing a file via git soft-deletes the Docmost page"
|
||||
cd "$WORK/clone2" 2>/dev/null || cd "$WORK/clone" || exit 1
|
||||
git config user.email e2e@test >/dev/null; git config user.name e2e >/dev/null
|
||||
delfile=$(find . -maxdepth 1 -name "*$NEW_TITLE*.md" | head -1)
|
||||
if [ -n "$delfile" ]; then
|
||||
git rm -q "$delfile"
|
||||
git commit -qm "e2e delete: $NEW_TITLE"
|
||||
if gpush; then
|
||||
sleep 2
|
||||
deleted=$(psqlq "select count(*) from pages where space_id='$SPACE_ID' and title='$NEW_TITLE' and deleted_at is not null;")
|
||||
[ "${deleted:-0}" -ge 1 ] && ok "page '$NEW_TITLE' was soft-deleted (in Trash)" || bad "page '$NEW_TITLE' not soft-deleted after git rm"
|
||||
else
|
||||
bad "push (delete) failed"
|
||||
fi
|
||||
else
|
||||
bad "delete target file not found in clone"
|
||||
fi
|
||||
cd "$WORK" || exit 1
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
say "3-way merge: a git edit to one part keeps the rest of the (test) page"
|
||||
# Re-clone fresh, append a second unique line to the SAME dedicated page, push,
|
||||
# then confirm BOTH markers coexist — the body merge did not clobber the first.
|
||||
rm -rf "$WORK/cmerge"
|
||||
gitc clone -q "$GIT_URL" "$WORK/cmerge" 2>/dev/null
|
||||
cd "$WORK/cmerge" || exit 1
|
||||
git config user.email e2e@test >/dev/null; git config user.name e2e >/dev/null
|
||||
mfile=$(grep -rl "$TEST_ID" --include='*.md' . | head -1)
|
||||
if [ -n "$mfile" ]; then
|
||||
MARK2="E2E-MERGE-$RANDOM$RANDOM"
|
||||
printf '\n## %s\n' "$MARK2" >> "$mfile"
|
||||
git commit -aqm "e2e merge: $MARK2"
|
||||
if gpush; then
|
||||
sleep 2
|
||||
both=$(psqlq "select count(*) from pages where id='$TEST_ID' and content::text like '%$MARK2%' and content::text like '%E2E-PUSH-%';")
|
||||
[ "${both:-0}" -ge 1 ] && ok "new edit added without losing prior content (3-way merge)" || bad "3-way merge lost content (both markers not present)"
|
||||
else
|
||||
bad "push (merge) failed"
|
||||
fi
|
||||
else
|
||||
bad "test page .md not found in the clone"
|
||||
fi
|
||||
cd "$WORK" || exit 1
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
say "RESULTS: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ] && exit 0 || exit 1
|
||||
@@ -0,0 +1,29 @@
|
||||
// Jest global setup (runs before each test module loads).
|
||||
//
|
||||
// react-dom@18 (pulled in transitively via @docmost/editor-ext -> @tiptap/react
|
||||
// -> react-dom, e.g. through the math node) reads `navigator` at MODULE-INIT
|
||||
// time. The server jest config uses `testEnvironment: "node"`, which has no
|
||||
// `navigator`, so ANY spec that transitively imports the editor schema/engine
|
||||
// (e.g. the git-sync HTTP service specs, which reach the conversion engine)
|
||||
// fails to LOAD with "ReferenceError: navigator is not defined". These specs
|
||||
// never exercise the DOM — they just can't survive the import. Provide the
|
||||
// minimal browser globals those modules touch at import so the specs run.
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const g = globalThis as any;
|
||||
|
||||
if (typeof g.navigator === "undefined") {
|
||||
// react-dom only reads navigator.userAgent at init; keep it minimal.
|
||||
Object.defineProperty(g, "navigator", {
|
||||
value: { userAgent: "node", platform: "node" },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof g.MessageChannel === "undefined") {
|
||||
// react-dom's scheduler references MessageChannel at init in some builds.
|
||||
g.MessageChannel = class {
|
||||
port1 = { postMessage() {}, close() {}, onmessage: null };
|
||||
port2 = { postMessage() {}, close() {}, onmessage: null };
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user