mirror of
https://github.com/wekan/wekan.git
synced 2026-11-04 17:10:08 -05:00
History: two changes at once no longer fork a board's History chain.
Admin Panel -> Problems showed HistoryIntegrity, critical, "history-fork", 89,062 times. Each History row names the row before it, and the legacy append (every board not migrated to the coordinated chain) read "the newest row" and inserted after it as two separate steps: two changes saved at the same moment on one board - two members, or one action whose hooks record twice at once - both named the same row. Nothing was tampered with, but the audit called it tampering, and undo, redo and restore were refused for both rows. The writer gate around the legacy append counts its writers for a migration; it does not make them take turns. - models/lib/historyLegacyAppend.js: a board's appends take turns in this server, and each links after the chain's tip - the newest row, then its successors, as Sync History already did - so a createdAt tie does not fork either. The ordinary recorder, the Scrum restore writer and Sync's legacy append all use it. Several servers writing one board at the same moment can still race; the coordinated chain is the answer to that. - The hourly audit reported every broken row again every hour, which is where 89,062 came from; each finding is now reported once (historyIntegrityReported). It reads the History board by board instead of all at once, and a fork of two intact rows saved within a minute of each other is reported as what it is - integrity.historyConcurrentFork, medium, "a History writer race, not tampering" - while anything else stays critical. Existing forks stay as they are (a row's hash cannot be changed), and undo of their two rows is still refused. tests/historyLegacyAppend.test.cjs: the old append forks under concurrency (negative control), twenty appends at once on two boards make two straight chains, a createdAt tie follows the chain, a failed append does not stop the next, the audit tells a race from a changed or late row, and no append in models/ or server/ links by "newest" without taking turns. Thanks to xet7 !
This commit is contained in:
1 parent
5481268558
commit
66dd9b0938
8 files changed
+306
-63
No files matched your search
@@ -172,15 +172,13 @@ ChangeHistory.record = async function record(options) {
|
||||
createdAt,
|
||||
superseded: false,
|
||||
};
|
||||
const legacy = async document => {
|
||||
const previous = await ChangeHistory.findOneAsync(
|
||||
{ boardId, integrityHash: { $nin: [null, ''] } },
|
||||
{ sort: { createdAt: -1 } },
|
||||
);
|
||||
// One append at a time per board, after the chain's tip
|
||||
// (models/lib/historyLegacyAppend.js): two at once used to fork it.
|
||||
const legacy = document => {
|
||||
const { hashHistoryRow } = require('/models/lib/changeHistoryIntegrity');
|
||||
const saved = { ...document, createdAt: new Date(), previousHash: previous ? previous.integrityHash : null };
|
||||
saved.integrityHash = hashHistoryRow(saved);
|
||||
return ChangeHistory.insertAsync(saved);
|
||||
const { appendLegacyHistoryRow } = require('/models/lib/historyLegacyAppend');
|
||||
return appendLegacyHistoryRow({ history: ChangeHistory, boardId, document, hash: hashHistoryRow,
|
||||
prepare: doc => ({ ...doc, createdAt: new Date() }) });
|
||||
};
|
||||
const write = async append => {
|
||||
// A NEW change clears this user's redo stack on this board (History.md §7c):
|
||||
|
||||
@@ -104,6 +104,7 @@ function verifyHistoryRows(rows) {
|
||||
const list = Array.isArray(rows) ? rows.filter(row => row && row.integrityHash) : [];
|
||||
const hashes = new Map(list.map(row => [`${row.boardId}:${row.integrityHash}`, row]));
|
||||
const children = new Map();
|
||||
const firstChild = new Map();
|
||||
const roots = new Map();
|
||||
const failures = [];
|
||||
for (const row of list) {
|
||||
@@ -113,9 +114,11 @@ function verifyHistoryRows(rows) {
|
||||
}
|
||||
if (row.previousHash) {
|
||||
const key = `${row.boardId}:${row.previousHash}`;
|
||||
const first = firstChild.get(key);
|
||||
const count = (children.get(key) || 0) + 1;
|
||||
children.set(key, count);
|
||||
if (count > 1) failures.push({ row, reason: 'history-fork' });
|
||||
if (!first) firstChild.set(key, row);
|
||||
else failures.push({ row, reason: 'history-fork', sibling: first });
|
||||
} else {
|
||||
const count = (roots.get(row.boardId) || 0) + 1;
|
||||
roots.set(row.boardId, count);
|
||||
@@ -125,6 +128,17 @@ function verifyHistoryRows(rows) {
|
||||
return failures;
|
||||
}
|
||||
|
||||
// A fork whose two rows are both intact and were saved within moments of
|
||||
// each other is two changes written at once - the legacy append race
|
||||
// (models/lib/historyLegacyAppend.js) - not a row somebody put there later.
|
||||
const CONCURRENT_FORK_MS = 60 * 1000;
|
||||
function isConcurrentFork(failure, windowMs = CONCURRENT_FORK_MS) {
|
||||
if (!failure || failure.reason !== 'history-fork' || !failure.sibling) return false;
|
||||
const at = row => new Date(row.createdAt).getTime();
|
||||
const gap = Math.abs(at(failure.row) - at(failure.sibling));
|
||||
return rowHashIsValid(failure.row) && rowHashIsValid(failure.sibling) && Number.isFinite(gap) && gap <= windowMs;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
canonical, sha256, hashHistoryRow, rowHashIsValid, verifyHistoryRows, PROTECTED,
|
||||
canonical, sha256, hashHistoryRow, rowHashIsValid, verifyHistoryRows, isConcurrentFork, CONCURRENT_FORK_MS, PROTECTED,
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
'use strict';
|
||||
|
||||
// Appending one row to a board's History chain on the legacy path (every board
|
||||
// not migrated to the coordinated chain, server/lib/storedHistoryChain.js).
|
||||
//
|
||||
// Each row names the row before it (previousHash). The append used to read
|
||||
// "the newest row" and insert after it as two separate steps, so two changes
|
||||
// saved at the same moment on one board - two members, or one action whose
|
||||
// hooks record twice at once - both named the same row: a fork. The audit
|
||||
// then reported it as critical HistoryIntegrity tampering every hour, and
|
||||
// undo, redo and restore were refused for both rows (requireHistoryIntegrity).
|
||||
//
|
||||
// Now a board's appends take turns within this server, and each links to the
|
||||
// chain's TIP: from the newest row, following successors, as Sync History
|
||||
// already did (a tie on createdAt no longer gives one row two successors).
|
||||
// Servers that write the same board at once can still race; the coordinated
|
||||
// chain is the fix for that, and the audit tells the two kinds of fork apart.
|
||||
|
||||
const queues = new Map();
|
||||
|
||||
// Runs `work` after every earlier append for this board in this server has
|
||||
// finished; a failed append does not stop the next one.
|
||||
function serializeHistoryAppend(boardId, work) {
|
||||
const key = String(boardId);
|
||||
const run = (queues.get(key) || Promise.resolve()).then(work, work);
|
||||
const tail = run.then(() => {}, () => {});
|
||||
queues.set(key, tail);
|
||||
tail.then(() => { if (queues.get(key) === tail) queues.delete(key); });
|
||||
return run;
|
||||
}
|
||||
|
||||
const MAX_TIP_STEPS = 10000;
|
||||
|
||||
// The chain's last row: the newest hashed row, then its successors.
|
||||
async function historyChainTip(history, boardId) {
|
||||
let tip = await history.findOneAsync({ boardId, integrityHash: { $nin: [null, ''] } },
|
||||
{ sort: { createdAt: -1 }, transform: null });
|
||||
for (let steps = 0; tip && steps < MAX_TIP_STEPS; steps += 1) {
|
||||
const next = await history.findOneAsync({ boardId, previousHash: tip.integrityHash }, { transform: null });
|
||||
if (!next) break;
|
||||
tip = next;
|
||||
}
|
||||
return tip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append `document` to the board's chain.
|
||||
* history findOneAsync(selector, options) and insertAsync(document)
|
||||
* hash hashHistoryRow (models/lib/changeHistoryIntegrity.js)
|
||||
* prepare optional: the document as saved, made at its turn (createdAt)
|
||||
* Resolves to what insertAsync resolves to.
|
||||
*/
|
||||
function appendLegacyHistoryRow({ history, boardId, document, hash, prepare = doc => doc }) {
|
||||
return serializeHistoryAppend(boardId, async () => {
|
||||
const previous = await historyChainTip(history, boardId);
|
||||
const saved = { ...prepare(document), previousHash: previous ? previous.integrityHash : null };
|
||||
saved.integrityHash = hash(saved);
|
||||
return history.insertAsync(saved);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { serializeHistoryAppend, historyChainTip, appendLegacyHistoryRow, MAX_TIP_STEPS };
|
||||
@@ -167,6 +167,10 @@ const CATALOG = {
|
||||
'injection.nosql': { category: 'injection', bleed: 'SelectorBleed', severity: 'high', cwe: 'CWE-943' },
|
||||
'injection.sql': { category: 'injection', bleed: 'EscapeBleed', severity: 'critical', cwe: 'CWE-89' },
|
||||
'integrity.history': { category: 'integrity', bleed: 'HistoryIntegrity', severity: 'critical', cwe: 'CWE-345' },
|
||||
// Two intact History rows saved within moments of each other naming the
|
||||
// same previous row: the legacy append race (models/lib/historyLegacyAppend.js),
|
||||
// not tampering. Undo of those two rows is refused, so it is still shown.
|
||||
'integrity.historyConcurrentFork': { category: 'integrity', bleed: 'HistoryIntegrity', severity: 'medium', cwe: 'CWE-362' },
|
||||
'integrity.file': { category: 'integrity', bleed: 'StorageBleed', severity: 'high', cwe: 'CWE-353' },
|
||||
// Reply-by-email (#2414): a mail provider, not a logged-in user, calls
|
||||
// /api/inbound-email, so the signed reply address is what authorizes a
|
||||
|
||||
@@ -1,42 +1,79 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import ChangeHistory from '/models/changeHistory';
|
||||
import SecurityLog from '/server/lib/securityLog';
|
||||
import { runWhenCpuLow, pauseIfBusy } from '/server/lib/cpuMonitor';
|
||||
import { ensureIndex } from '/server/lib/mongoStartup';
|
||||
|
||||
const { verifyHistoryRows } = require('/models/lib/changeHistoryIntegrity');
|
||||
const BATCH = 250;
|
||||
const { verifyHistoryRows, isConcurrentFork } = require('/models/lib/changeHistoryIntegrity');
|
||||
const PAUSE_MS = 100;
|
||||
|
||||
export async function runHistoryIntegrityScan() {
|
||||
const rows = await ChangeHistory.find(
|
||||
{ integrityHash: { $nin: [null, ''] } },
|
||||
{ sort: { boardId: 1, createdAt: 1 } },
|
||||
).fetchAsync();
|
||||
const found = verifyHistoryRows(rows);
|
||||
let failures = 0;
|
||||
for (let offset = 0; offset < found.length; offset += BATCH) {
|
||||
await pauseIfBusy(PAUSE_MS);
|
||||
for (const failure of found.slice(offset, offset + BATCH)) {
|
||||
failures += 1;
|
||||
SecurityLog.record({
|
||||
key: 'integrity.history',
|
||||
action: 'detected',
|
||||
detail: `background history audit reason=${failure.reason} ` +
|
||||
`row=${failure.row._id || 'unknown'} board=${failure.row.boardId || 'unknown'} ` +
|
||||
`expected=${failure.row.integrityHash || 'missing'} previous=${failure.row.previousHash || 'genesis'}; ` +
|
||||
'username/IP unavailable for background filesystem or database tampering',
|
||||
});
|
||||
}
|
||||
await new Promise(resolve => Meteor.setTimeout(resolve, PAUSE_MS));
|
||||
// What this audit has already reported: { _id: '<row>:<reason>', reportedAt }.
|
||||
// A broken row stays broken, and the audit runs every hour; reporting it again
|
||||
// each time only grew one Problems row's count (89,062 for a handful of forks)
|
||||
// and buried what was new. Each finding is reported once.
|
||||
export const HistoryIntegrityReported = new Mongo.Collection('historyIntegrityReported');
|
||||
HistoryIntegrityReported.deny({ insert: () => true, update: () => true, remove: () => true });
|
||||
|
||||
async function firstReport(failure) {
|
||||
try {
|
||||
await HistoryIntegrityReported.rawCollection().insertOne({
|
||||
_id: `${failure.row._id || 'unknown'}:${failure.reason}`, reportedAt: new Date() });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && error.code === 11000) return false;
|
||||
throw error;
|
||||
}
|
||||
return { checked: rows.length, failures };
|
||||
}
|
||||
|
||||
function report(failure) {
|
||||
const where = `row=${failure.row._id || 'unknown'} board=${failure.row.boardId || 'unknown'} ` +
|
||||
`expected=${failure.row.integrityHash || 'missing'} previous=${failure.row.previousHash || 'genesis'}`;
|
||||
if (isConcurrentFork(failure)) {
|
||||
SecurityLog.record({
|
||||
key: 'integrity.historyConcurrentFork',
|
||||
action: 'detected',
|
||||
detail: `background history audit reason=history-fork-concurrent ${where} sibling=${failure.sibling._id || 'unknown'}; ` +
|
||||
'two changes on this board were saved at the same moment and name the same previous row - a History writer race, ' +
|
||||
'not tampering; undo of these two rows is refused',
|
||||
});
|
||||
return;
|
||||
}
|
||||
SecurityLog.record({
|
||||
key: 'integrity.history',
|
||||
action: 'detected',
|
||||
detail: `background history audit reason=${failure.reason} ${where}; ` +
|
||||
'username/IP unavailable for background filesystem or database tampering',
|
||||
});
|
||||
}
|
||||
|
||||
// Board by board, so the whole History is never in memory at once.
|
||||
export async function runHistoryIntegrityScan() {
|
||||
const boardIds = await ChangeHistory.rawCollection().distinct('boardId', { integrityHash: { $nin: [null, ''] } });
|
||||
let checked = 0, failures = 0, reported = 0;
|
||||
for (const boardId of boardIds) {
|
||||
await pauseIfBusy(PAUSE_MS);
|
||||
const rows = await ChangeHistory.find(
|
||||
{ boardId, integrityHash: { $nin: [null, ''] } },
|
||||
{ sort: { createdAt: 1 }, transform: null },
|
||||
).fetchAsync();
|
||||
checked += rows.length;
|
||||
for (const failure of verifyHistoryRows(rows)) {
|
||||
failures += 1;
|
||||
if (await firstReport(failure)) { reported += 1; report(failure); }
|
||||
}
|
||||
}
|
||||
return { checked, failures, reported };
|
||||
}
|
||||
|
||||
if (Meteor.isServer && process.env.WEKAN_HISTORY_INTEGRITY_SCAN !== 'false') {
|
||||
Meteor.startup(() => Meteor.setInterval(() => {
|
||||
runWhenCpuLow('change history integrity audit', runHistoryIntegrityScan,
|
||||
{ maxWaitMs: 55 * 60 * 1000 }).catch(() => {});
|
||||
}, 60 * 60 * 1000));
|
||||
Meteor.startup(() => {
|
||||
ensureIndex(HistoryIntegrityReported, { reportedAt: 1 }).catch(() => {});
|
||||
Meteor.setInterval(() => {
|
||||
runWhenCpuLow('change history integrity audit', runHistoryIntegrityScan,
|
||||
{ maxWaitMs: 55 * 60 * 1000 }).catch(() => {});
|
||||
}, 60 * 60 * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
export default { runHistoryIntegrityScan };
|
||||
@@ -1,4 +1,5 @@
|
||||
const { sha256, canonical, hashHistoryRow, rowHashIsValid } = require('../../models/lib/changeHistoryIntegrity');
|
||||
const { appendLegacyHistoryRow } = require('../../models/lib/historyLegacyAppend');
|
||||
|
||||
// Only restoration events: ordinary edits retain the model's best-effort writer
|
||||
// and redo invalidation. The caller retains its recovery journal until this
|
||||
@@ -29,13 +30,8 @@ async function recordScrumRestoreOnce(history, options) {
|
||||
if (existing) return matches(existing);
|
||||
const row = { ...event, _id: id, createdAt: new Date(), undone: false,
|
||||
undoneAt: null, superseded: false };
|
||||
const legacy = async document => {
|
||||
const previous = await history.findOneAsync(
|
||||
{ boardId, integrityHash: { $nin: [null, ''] } }, { sort: { createdAt: -1 } });
|
||||
const saved = { ...document, previousHash: previous?.integrityHash ?? null };
|
||||
saved.integrityHash = hashHistoryRow(saved);
|
||||
return history.insertAsync(saved);
|
||||
};
|
||||
// After the chain's tip, one append at a time per board (historyLegacyAppend.js).
|
||||
const legacy = document => appendLegacyHistoryRow({ history, boardId, document, hash: hashHistoryRow });
|
||||
const write = async append => {
|
||||
let writeError;
|
||||
try { await append(row); } catch (error) { writeError = error; }
|
||||
|
||||
@@ -8,6 +8,7 @@ const { appendHistoryChain, historyChainId, validateHistoryChainHead } = require
|
||||
const { initializeHistoryChain } = require('./historyChainBootstrap');
|
||||
const { withHistoryWriter, recoverExpiredHistoryWriters, historyWriterLeasePolicy } = require('./historyWriterGate');
|
||||
const { hashHistoryRow } = require('../../models/lib/changeHistoryIntegrity');
|
||||
const { serializeHistoryAppend, historyChainTip } = require('../../models/lib/historyLegacyAppend');
|
||||
const { migrateHistoryChain } = require('./historyChainMigration');
|
||||
|
||||
export const HistoryWriterGates = new Mongo.Collection('historyWriterGates');
|
||||
@@ -110,24 +111,21 @@ ChangeHistory.appendSyncHistoryRow = async ({ row, mode, assertCurrent, fencedIn
|
||||
}
|
||||
// After the chain's TIP: from the newest hashed row, follow its successors.
|
||||
// A Sync batch's rows share one planned createdAt, so "newest" alone is a
|
||||
// tie and would give an earlier batch row a second successor - a fork.
|
||||
let previous = await ChangeHistory.findOneAsync(
|
||||
{ boardId: prepared.boardId, integrityHash: { $nin: [null, ''] } }, { sort: { createdAt: -1 }, transform: null });
|
||||
for (let steps = 0; previous && steps < 10000; steps++) {
|
||||
const next = await ChangeHistory.findOneAsync({ boardId: prepared.boardId, previousHash: previous.integrityHash },
|
||||
{ transform: null });
|
||||
if (!next) break;
|
||||
previous = next;
|
||||
}
|
||||
const saved = { ...prepared, previousHash: previous ? previous.integrityHash : null };
|
||||
saved.integrityHash = hashHistoryRow(saved);
|
||||
await assertCurrent();
|
||||
try { await fencedInsert(prepared._id, () => ChangeHistory.insertAsync(saved, { removeEmptyStrings: false, trimStrings: false })); }
|
||||
catch (error) {
|
||||
const found = await ChangeHistory.findOneAsync(prepared._id, { transform: null });
|
||||
if (!found || found.boardId !== prepared.boardId) throw error;
|
||||
}
|
||||
return prepared._id;
|
||||
// tie and would give an earlier batch row a second successor - a fork. And
|
||||
// one append at a time per board, like every legacy append
|
||||
// (models/lib/historyLegacyAppend.js).
|
||||
return serializeHistoryAppend(prepared.boardId, async () => {
|
||||
const previous = await historyChainTip(ChangeHistory, prepared.boardId);
|
||||
const saved = { ...prepared, previousHash: previous ? previous.integrityHash : null };
|
||||
saved.integrityHash = hashHistoryRow(saved);
|
||||
await assertCurrent();
|
||||
try { await fencedInsert(prepared._id, () => ChangeHistory.insertAsync(saved, { removeEmptyStrings: false, trimStrings: false })); }
|
||||
catch (error) {
|
||||
const found = await ChangeHistory.findOneAsync(prepared._id, { transform: null });
|
||||
if (!found || found.boardId !== prepared.boardId) throw error;
|
||||
}
|
||||
return prepared._id;
|
||||
});
|
||||
};
|
||||
|
||||
// No automatic rollout: callers must prove older server versions cannot write.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
'use strict';
|
||||
|
||||
// HistoryIntegrity "history-fork" (Admin Panel -> Problems, 2026-10-14): two
|
||||
// changes saved at the same moment on one board both named the same previous
|
||||
// row, because the legacy append read the newest row and inserted after it in
|
||||
// two steps. models/lib/historyLegacyAppend.js makes a board's appends take
|
||||
// turns and links each after the chain's tip; the audit reports each finding
|
||||
// once and tells a same-moment fork from tampering.
|
||||
// Run: node tests/historyLegacyAppend.test.cjs
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { appendLegacyHistoryRow, serializeHistoryAppend, historyChainTip } = require('../models/lib/historyLegacyAppend.js');
|
||||
const { hashHistoryRow, verifyHistoryRows, isConcurrentFork, CONCURRENT_FORK_MS } = require('../models/lib/changeHistoryIntegrity.js');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const read = f => fs.readFileSync(path.join(ROOT, f), 'utf8');
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
// A History collection whose reads and writes each take a turn of the event
|
||||
// loop, as a database round trip does - which is what lets two appends interleave.
|
||||
function memoryHistory() {
|
||||
const rows = [];
|
||||
let clock = Date.parse('2026-10-14T18:00:00Z');
|
||||
const matches = (row, selector) => Object.entries(selector).every(([key, want]) => {
|
||||
if (want && typeof want === 'object' && Array.isArray(want.$nin)) return !want.$nin.includes(row[key] ?? null);
|
||||
return (row[key] ?? null) === want;
|
||||
});
|
||||
return {
|
||||
rows,
|
||||
now: () => new Date(clock++),
|
||||
async findOneAsync(selector, options = {}) {
|
||||
await tick();
|
||||
let found = rows.filter(row => matches(row, selector));
|
||||
if (options.sort && options.sort.createdAt === -1) found = found.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return found[0] ? { ...found[0] } : undefined;
|
||||
},
|
||||
async insertAsync(document) {
|
||||
await tick();
|
||||
rows.push({ ...document });
|
||||
return document._id;
|
||||
},
|
||||
};
|
||||
}
|
||||
const change = n => ({ _id: `r${n}`, boardId: 'b1', entityType: 'card', entityId: `c${n}`, changeType: 'edited',
|
||||
userId: 'u1', group: 'card', previousContent: { title: `${n}` }, newContent: { title: `${n + 1}` } });
|
||||
|
||||
(async () => {
|
||||
// Negative control: the old append (read the newest row, then insert) forks
|
||||
// when two run at once - the fault the audit reported.
|
||||
const old = memoryHistory();
|
||||
const oldAppend = async document => {
|
||||
const previous = await old.findOneAsync({ boardId: 'b1', integrityHash: { $nin: [null, ''] } }, { sort: { createdAt: -1 } });
|
||||
const saved = { ...document, createdAt: old.now(), previousHash: previous ? previous.integrityHash : null };
|
||||
saved.integrityHash = hashHistoryRow(saved);
|
||||
return old.insertAsync(saved);
|
||||
};
|
||||
await oldAppend(change(0));
|
||||
await Promise.all([1, 2, 3, 4, 5].map(n => oldAppend(change(n))));
|
||||
const oldFailures = verifyHistoryRows(old.rows);
|
||||
assert.ok(oldFailures.some(f => f.reason === 'history-fork'), 'the old append forks under concurrency');
|
||||
assert.ok(oldFailures.filter(f => f.reason === 'history-fork').every(f => isConcurrentFork(f)), 'and the audit sees it as a race');
|
||||
console.log(' ok - the old append forks when two changes are saved at once (negative control)');
|
||||
|
||||
// The fix: twenty at once, on two boards, make two straight chains.
|
||||
const history = memoryHistory();
|
||||
const append = (boardId, n) => appendLegacyHistoryRow({ history, boardId, document: { ...change(n), boardId, _id: `${boardId}-${n}` },
|
||||
hash: hashHistoryRow, prepare: doc => ({ ...doc, createdAt: history.now() }) });
|
||||
await Promise.all(Array.from({ length: 20 }, (_, n) => append(n % 2 ? 'b1' : 'b2', n)));
|
||||
assert.equal(history.rows.length, 20);
|
||||
assert.deepEqual(verifyHistoryRows(history.rows), [], 'no fork, no second root, every predecessor present');
|
||||
for (const boardId of ['b1', 'b2']) {
|
||||
const chain = history.rows.filter(row => row.boardId === boardId);
|
||||
assert.equal(chain.filter(row => row.previousHash === null).length, 1, `${boardId}: one root`);
|
||||
assert.equal((await historyChainTip(history, boardId)).integrityHash,
|
||||
chain.find(row => !chain.some(other => other.previousHash === row.integrityHash)).integrityHash, `${boardId}: the tip is the last row`);
|
||||
}
|
||||
console.log(' ok - twenty appends at once on two boards make two straight chains');
|
||||
|
||||
// The tip, not merely the newest: a row with an older createdAt that already
|
||||
// has a successor is followed to its end (Sync batches share one createdAt).
|
||||
const tied = memoryHistory();
|
||||
const at = new Date('2026-10-14T18:00:00Z');
|
||||
await appendLegacyHistoryRow({ history: tied, boardId: 'b1', document: change(1), hash: hashHistoryRow, prepare: doc => ({ ...doc, createdAt: at }) });
|
||||
await appendLegacyHistoryRow({ history: tied, boardId: 'b1', document: change(2), hash: hashHistoryRow, prepare: doc => ({ ...doc, createdAt: at }) });
|
||||
await appendLegacyHistoryRow({ history: tied, boardId: 'b1', document: change(3), hash: hashHistoryRow, prepare: doc => ({ ...doc, createdAt: at }) });
|
||||
assert.deepEqual(verifyHistoryRows(tied.rows), [], 'a createdAt tie does not fork');
|
||||
console.log(' ok - a createdAt tie follows the chain to its tip');
|
||||
|
||||
// A failed append does not stop the next one on that board.
|
||||
const order = [];
|
||||
const failing = serializeHistoryAppend('b9', async () => { order.push('a'); throw new Error('insert failed'); });
|
||||
const next = serializeHistoryAppend('b9', async () => { order.push('b'); return 'ok'; });
|
||||
await assert.rejects(failing, /insert failed/);
|
||||
assert.equal(await next, 'ok');
|
||||
assert.deepEqual(order, ['a', 'b']);
|
||||
console.log(' ok - a failed append is reported to its caller and the next one runs');
|
||||
|
||||
// The audit: a same-moment fork of two intact rows is a race; anything else is not.
|
||||
const [fork] = oldFailures.filter(f => f.reason === 'history-fork');
|
||||
assert.ok(fork.sibling, 'a fork names the row it forked with');
|
||||
assert.equal(isConcurrentFork({ ...fork, row: { ...fork.row, createdAt: new Date(new Date(fork.sibling.createdAt).getTime() + CONCURRENT_FORK_MS + 1) } }), false,
|
||||
'a fork written long after its sibling is not a race (the row changed, so its hash fails too)');
|
||||
assert.equal(isConcurrentFork({ ...fork, row: { ...fork.row, newContent: { title: 'tampered' } } }), false, 'a changed row is not a race');
|
||||
assert.equal(isConcurrentFork({ reason: 'predecessor-missing', row: fork.row, sibling: fork.sibling }), false);
|
||||
assert.equal(isConcurrentFork({ reason: 'history-fork', row: fork.row }), false, 'no sibling, no verdict');
|
||||
console.log(' ok - the audit tells a same-moment fork from a changed or late row');
|
||||
|
||||
// Every legacy append goes through it; nothing else links a row by "newest".
|
||||
assert.match(read('models/changeHistory.js'), /appendLegacyHistoryRow\(\{ history: ChangeHistory, boardId, document, hash: hashHistoryRow,/);
|
||||
assert.match(read('server/lib/scrumHistoryRestoreWriter.js'), /const legacy = document => appendLegacyHistoryRow\(\{ history, boardId, document, hash: hashHistoryRow \}\);/);
|
||||
assert.match(read('server/lib/storedHistoryChain.js'), /return serializeHistoryAppend\(prepared\.boardId, async \(\) => \{\s*const previous = await historyChainTip\(ChangeHistory, prepared\.boardId\);/);
|
||||
const offenders = [];
|
||||
const walk = dir => {
|
||||
for (const entry of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
const rel = `${dir}/${entry.name}`;
|
||||
if (entry.isDirectory()) { if (!['node_modules', '_build', 'tests'].includes(entry.name)) walk(rel); continue; }
|
||||
if (!/\.(c|m)?js$/.test(entry.name)) continue;
|
||||
const src = read(rel);
|
||||
if (/previousHash:\s*previous/.test(src) && !/historyChainTip\(|appendLegacyHistoryRow|appendHistoryChain/.test(src)) offenders.push(rel);
|
||||
}
|
||||
};
|
||||
['models', 'server'].forEach(walk);
|
||||
assert.deepEqual(offenders, [], 'a History append that links by "newest" without taking turns forks');
|
||||
const scan = read('server/lib/historyIntegrityScan.js');
|
||||
assert.match(scan, /if \(await firstReport\(failure\)\) \{ reported \+= 1; report\(failure\); \}/, 'each finding reported once');
|
||||
assert.match(scan, /distinct\('boardId'/, 'board by board, never the whole History in memory');
|
||||
assert.match(scan, /key: 'integrity\.historyConcurrentFork'/);
|
||||
assert.match(read('models/lib/securityCategories.js'), /'integrity\.historyConcurrentFork': \{ category: 'integrity', bleed: 'HistoryIntegrity', severity: 'medium'/);
|
||||
console.log(' ok - every legacy append takes turns; the audit reports once, board by board');
|
||||
|
||||
console.log('\nhistoryLegacyAppend: all tests passed');
|
||||
})().catch(error => { console.error(error); process.exit(1); });
|
||||
Reference in new issue
Block a user