fix(git-sync): close #119 blockers — dead edit-revert guard, cross-space guard, red suite (F5/S2/G1/A1/F7)
F5 (HIGH data-loss): guard #2 (GS-EDIT-REVERT) called a local key-sorting equality that never matched a real page (block ids + materialized defaults differ), so the guard was dead and a web edit on a git-sync space was silently reverted within one poll cycle. Use the package's authoritative docsCanonicallyEqual (strips block id + normalizes KNOWN_DEFAULTS), wired through the git-sync loader like sanitizeTitle; delete the dead local canonicalize/canonicalJsonEqual. S2 (security): importPageMarkdown targeted a page by the vault-file id without a spaceId check (deletePage had one) — a space-A vault file carrying space-B's page id could resurrect/overwrite/clear B's page. Mirror deletePage's guard: skip when the loaded page lives in a different space than ctx.spaceId. G1 (jest green): add sanitizeTitle + docsCanonicallyEqual to the loadGitSync mock; update the converter-gate + package golden expectations to the genuinely-fixed output (paragraph textAlign now round-trips, multi-block table cells emit HTML tables); fix the orchestrator spec's stale mock so the per-space enabled gate (added later) is satisfied. A1: the converter dropped heading textAlign on export (bare '## text'); emit a styled <hN> when aligned, symmetric to paragraphs — round-trips losslessly (level + align), no churn for unaligned headings. F7 (docs): reword the false 'single choke point' title-strip comment; correct push.ts docstrings that still described the removed standalone-CLI/daemon model. Adds regression tests: the F5 acceptance test (canonically-equal content with real uuids => writePageBody NOT called), the S2 cross-space import guard, and the A1 heading round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,10 +56,19 @@ interface BuildOptions {
|
||||
vaultOverrides?: Record<string, unknown>;
|
||||
/**
|
||||
* The row `buildSettings` reads for the per-space `autoMergeConflicts` flag
|
||||
* (`executeTakeFirst`). Default: the SAFE off value. Pass `undefined` to model
|
||||
* a missing row (no space / no settings).
|
||||
* (`executeTakeFirst` on the `autoMergeConflicts`-aliased query). Default: the
|
||||
* SAFE off value. Pass `undefined` to model a missing row (no space / no
|
||||
* settings).
|
||||
*/
|
||||
settingsRow?: { autoMergeConflicts: boolean } | undefined;
|
||||
/**
|
||||
* The per-space opt-in flag `runOnce` reads via `isSpaceGitSyncEnabled`
|
||||
* (`executeTakeFirst` on the `enabled`-aliased query — a DIFFERENT query from
|
||||
* the `autoMergeConflicts` read above; commit c838fdee added it). Default
|
||||
* `true` so a build() space passes the per-space gate and the cycle proceeds.
|
||||
* Set `false` to model a space that did NOT opt in (skipped:'space-not-enabled').
|
||||
*/
|
||||
spaceEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface Built {
|
||||
@@ -83,6 +92,7 @@ function build(opts: BuildOptions = {}): Built {
|
||||
pollIntervalMs = 15000,
|
||||
debounceMs = 2000,
|
||||
vaultOverrides = {},
|
||||
spaceEnabled = true,
|
||||
} = opts;
|
||||
// Distinguish "key omitted" (default off row) from "key present but undefined"
|
||||
// (a deliberately MISSING settings row).
|
||||
@@ -138,15 +148,36 @@ function build(opts: BuildOptions = {}): Built {
|
||||
};
|
||||
const redisService = { getOrThrow: jest.fn(() => redis) };
|
||||
|
||||
// Chainable Kysely stub. `buildSettings` reads the space's
|
||||
// `gitSync.autoMergeConflicts` flag via
|
||||
// `selectFrom('spaces').select(...).where('id','=',id).executeTakeFirst()`;
|
||||
// default it to the SAFE off value. `enabledSpaces` uses `.execute()`.
|
||||
// Chainable Kysely stub. TWO distinct single-row queries run through the same
|
||||
// builder and both end in `executeTakeFirst`, so the stub must tell them apart
|
||||
// by the column ALIAS the real code selects (they are otherwise identical):
|
||||
// - `isSpaceGitSyncEnabled` (per-space opt-in gate in `runOnce`, added by
|
||||
// c838fdee): `.select(sql\`...->>'enabled' = 'true'\`.as('enabled'))` ->
|
||||
// the code reads `row?.enabled`. Return `{ enabled: spaceEnabled }`.
|
||||
// - `buildSettings`: `.select(sql\`...->>'autoMergeConflicts' = 'true'\`
|
||||
// .as('autoMergeConflicts'))` -> the code reads `row?.autoMergeConflicts`.
|
||||
// Return `settingsRow` (default SAFE off; `undefined` models a missing row).
|
||||
// `enabledSpaces` uses `.select([...strings]).execute()` (no alias, no
|
||||
// executeTakeFirst) and is stubbed/replaced in the tests that exercise it.
|
||||
const db = (() => {
|
||||
let lastAlias: string | undefined;
|
||||
const aliasOf = (sel: unknown): string | undefined => {
|
||||
try {
|
||||
const node = (sel as { toOperationNode?: () => any })?.toOperationNode?.();
|
||||
return node?.kind === 'AliasNode' ? node.alias?.name : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const builder: any = {
|
||||
select: () => builder,
|
||||
select: (sel: unknown) => {
|
||||
const alias = aliasOf(sel);
|
||||
if (alias) lastAlias = alias;
|
||||
return builder;
|
||||
},
|
||||
where: () => builder,
|
||||
executeTakeFirst: async () => settingsRow,
|
||||
executeTakeFirst: async () =>
|
||||
lastAlias === 'enabled' ? { enabled: spaceEnabled } : settingsRow,
|
||||
execute: async () => [],
|
||||
};
|
||||
return { selectFrom: () => builder };
|
||||
|
||||
Reference in New Issue
Block a user