Untested: protect library publication state and add broker bare-repo coverage (#1793)
This commit is contained in:
1 parent
c867cada97
commit
7dc4fb2cca
7 files changed
+243
-57
No files matched your search
@@ -115,3 +115,30 @@ threads that option into startServer; row C2-11b owns the actual route/page that
|
|||||||
webui/tests/library-write-options.test.mjs covers --send and the user transport boundary. 225 owns
|
webui/tests/library-write-options.test.mjs covers --send and the user transport boundary. 225 owns
|
||||||
libraryRevert dispatch; 288 adds only librarySave to avoid overlapping that ownership. Additional
|
libraryRevert dispatch; 288 adds only librarySave to avoid overlapping that ownership. Additional
|
||||||
scope reported to Sage and Fethry. No changes to an existing test.
|
scope reported to Sage and Fethry. No changes to an existing test.
|
||||||
|
|
||||||
|
## 2026-10-23 second static pass and dependency review
|
||||||
|
|
||||||
|
First push: c867cada9 on origin/bubba/row288, reported bubba-0005 to Sage/Webby/Mos and
|
||||||
|
bubba-0006 to Fethry/Ludwig. No PR created and no review/gate requested as complete.
|
||||||
|
|
||||||
|
Ludwig ludwig-0065 checked the exact sync diff against 286 without running anything. Token identity,
|
||||||
|
ordinary-sync exclusion, 286 R1 preservation, guard key and end-of-write tracking restoration fit.
|
||||||
|
F1: state publication could fail after served moved, leaving old state/new served. Root cause is
|
||||||
|
publication order without rollback, compared against sync's existing rollback. Added an unrun
|
||||||
|
filesystem rename-failure regression, then changed the narrow helper: on failure it validates the
|
||||||
|
state under the same lock, CAS-restoring served if state has not published the new commit. If rename
|
||||||
|
succeeded but directory fsync failed and the state validates against new served, it keeps that pair.
|
||||||
|
All failure cases answer outcome-unknown; tracking remains the commit already pushed remotely.
|
||||||
|
Verification is pending CLEAR, so this is a proposed fix, not a tested resolution.
|
||||||
|
|
||||||
|
Added the actual broker-to-bare tests for each M12 path refusal (both agent verbs), unchanged remote
|
||||||
|
and all mirror refs, exact write/delete notices, and human revert after fixture grant removal/hold.
|
||||||
|
The bare fixture moved to new library/tests/write-fixture.mjs so broker tests reuse real plumbing.
|
||||||
|
Added the raw socket proof-nested-save-name case to pin the parsed-verb large-request check.
|
||||||
|
Missing editor/slot tests now make the mirror metadata invalid first: reaching Git would change the
|
||||||
|
refusal, so the tests check the pre-Git gate rather than merely count remote commands.
|
||||||
|
|
||||||
|
Additional required doc hunk: packages/library/README.md appends the four verb contracts,
|
||||||
|
lastPush shape, exact notice, lock tradeoff and publication failure behavior. No existing test from
|
||||||
|
the dependency/base has changed. All new/modified tests in this pass were authored by this row.
|
||||||
|
`git diff --check` remains clean. No Node/tests/build/mutants run; 284 and CLEAR remain pending.
|
||||||
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { connect } from 'node:net';
|
||||||
import { Store } from '../src/store.mjs';
|
import { Store } from '../src/store.mjs';
|
||||||
import { Broker } from '../src/broker.mjs';
|
import { Broker } from '../src/broker.mjs';
|
||||||
import { Client } from '../src/client.mjs';
|
import { Client } from '../src/client.mjs';
|
||||||
@@ -11,12 +12,15 @@ import { serve } from '../src/server.mjs';
|
|||||||
import { mappingEntry } from '../../library/tests/helpers.mjs';
|
import { mappingEntry } from '../../library/tests/helpers.mjs';
|
||||||
import { LibraryError } from '../../library/src/problems.mjs';
|
import { LibraryError } from '../../library/src/problems.mjs';
|
||||||
import { grantDecision } from './host-fixtures.mjs';
|
import { grantDecision } from './host-fixtures.mjs';
|
||||||
|
import { world as bareWorld } from '../../library/tests/write-fixture.mjs';
|
||||||
|
import { writeLibraryDoc, saveLibraryFile, revertLibraryCommit } from '../../library/src/write.mjs';
|
||||||
|
import { git } from '../../library/tests/helpers.mjs';
|
||||||
|
|
||||||
const SHA = 'a'.repeat(40), NEXT = 'b'.repeat(40), PATH = 'Book One/Notes/Agent.md';
|
const SHA = 'a'.repeat(40), NEXT = 'b'.repeat(40), PATH = 'Book One/Notes/Agent.md';
|
||||||
function world(t) {
|
function world(t, options = {}) {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'bus-library-write-')), store = new Store(root);
|
const root = mkdtempSync(join(tmpdir(), 'bus-library-write-')), store = new Store(root);
|
||||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||||
const projects = { books: mappingEntry() }; projects.books.universes.harbor.supporting = ['Book One/Notes/'];
|
const projects = options.projects ?? { books: mappingEntry() }; projects.books.universes.harbor.supporting = ['Book One/Notes/'];
|
||||||
const authority = { withinRole: ['message.send', 'library.write'], crossRole: [] };
|
const authority = { withinRole: ['message.send', 'library.write'], crossRole: [] };
|
||||||
const roles = { bean: { definition: 'companion', authority, identity: { name: 'Bean', email: '[email protected]', gitea: { user: 'bean', url: 'https://git.example.invalid' } }, publish: ['books'] },
|
const roles = { bean: { definition: 'companion', authority, identity: { name: 'Bean', email: '[email protected]', gitea: { user: 'bean', url: 'https://git.example.invalid' } }, publish: ['books'] },
|
||||||
chief: { definition: 'chief-of-staff', authority }, quill: { definition: 'proofreader', authority: { withinRole: ['message.send'], crossRole: [] } } };
|
chief: { definition: 'chief-of-staff', authority }, quill: { definition: 'proofreader', authority: { withinRole: ['message.send'], crossRole: [] } } };
|
||||||
@@ -110,3 +114,64 @@ test('C2-02 transport: human library.file.save exceeds 64 KiB; agent write remai
|
|||||||
await assert.rejects(agent.call('library.doc.write', { ...args(), content }), { code: 'request-too-large' });
|
await assert.rejects(agent.call('library.doc.write', { ...args(), content }), { code: 'request-too-large' });
|
||||||
await assert.rejects(human.call('library.file.save', { project: 'books', path: PATH, base: SHA, content: 'x'.repeat(2 * 1024 * 1024) }), { code: 'request-too-large' });
|
await assert.rejects(human.call('library.file.save', { project: 'books', path: PATH, base: SHA, content: 'x'.repeat(2 * 1024 * 1024) }), { code: 'request-too-large' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('C2-02 M12 through broker: every refusal preserves remote, served and all mirror refs', async t => {
|
||||||
|
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
|
||||||
|
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot },
|
||||||
|
{ ...options, credential: { ...options.credential, file: bare.file }, command: bare.command });
|
||||||
|
for (const path of ['Book One/Chapters/Chapter 01.md', 'Inbox Note.md', 'Book One/Playground/What If.md',
|
||||||
|
'Book One/Notes/X.canvas', 'Book One/Notes/X.base', 'Book One/Notes/X.png', 'Second World/Chapters/01.md',
|
||||||
|
'/Book One/Notes/X.md', 'Book One/Notes/../X.md', 'Book One/./Notes/X.md', 'Book One//Notes/X.md',
|
||||||
|
'Book One/Notes/X\\Y.md', 'Book One/Notes/X\nY.md', 'Book One/Notes/Link.md', 'Book One/Notes/Submodule.md',
|
||||||
|
'Book One/Notes/agent.md', `Book One/Notes/${'é'.repeat(512)}.md`]) {
|
||||||
|
for (const verb of ['library.doc.write', 'library.doc.delete']) {
|
||||||
|
const before = bare.refs(), head = bare.remoteHead(), a = { ...args(), path, base: null };
|
||||||
|
if (verb.endsWith('delete')) delete a.content;
|
||||||
|
await assert.rejects(f.call('bean', verb, a), { code: 'library-write-story-path' }, path);
|
||||||
|
assert.equal(bare.refs(), before); assert.equal(bare.remoteHead(), head);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='bean'").n, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C2-02 broker + bare: exact write/delete notices, then human revert with revoked seat grant', async t => {
|
||||||
|
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
|
||||||
|
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot },
|
||||||
|
{ ...options, credential: { ...options.credential, file: bare.file }, command: bare.command });
|
||||||
|
f.library.revert = context => revertLibraryCommit({ ...context, dataRoot: bare.dataRoot },
|
||||||
|
{ credential: { ...bare.credential, kind: 'human' }, command: bare.command });
|
||||||
|
f.library.saveFile = context => saveLibraryFile({ ...context, dataRoot: bare.dataRoot },
|
||||||
|
{ credential: { ...bare.credential, kind: 'human' }, command: bare.command });
|
||||||
|
const base = git(bare.mirror, 'rev-parse', `served:${PATH}`);
|
||||||
|
const saved = await f.call('bean', 'library.doc.write', { ...args(), base });
|
||||||
|
assert.equal(saved.commit, bare.remoteHead());
|
||||||
|
f.store.run('DELETE FROM gitea_grants'); f.held.add('bean');
|
||||||
|
const reverted = await f.call('human', 'library.revert', { project: 'books', commit: saved.commit });
|
||||||
|
assert.equal(reverted.reverted, saved.commit);
|
||||||
|
assert.equal(git(bare.remote, 'show', `main:${PATH}`), 'before');
|
||||||
|
assert.equal(git(bare.remote, 'show', '-s', '--format=%an <%ae>', reverted.commit), 'Human Fixture <[email protected]>');
|
||||||
|
f.held.clear(); f.grant();
|
||||||
|
const deleted = await f.call('bean', 'library.doc.delete', { universe: 'harbor', path: PATH, base });
|
||||||
|
const messages = f.store.all("SELECT body FROM messages WHERE from_role='bean' AND to_role='human' ORDER BY seq");
|
||||||
|
assert.deepEqual(messages.map(m => JSON.parse(m.body)), [
|
||||||
|
{ notice: 'library-write', op: 'write', path: PATH, commit: saved.commit },
|
||||||
|
{ notice: 'library-write', op: 'delete', path: PATH, commit: deleted.commit }]);
|
||||||
|
assert.equal(git(bare.remote, 'ls-tree', '-r', deleted.commit, '--', PATH), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('C2-02 socket: a save name nested in proof cannot enlarge an agent envelope', async t => {
|
||||||
|
const f = world(t), path = join(f.root, 'raw-bus.sock');
|
||||||
|
const server = await serve({ broker: f.broker, path, authenticateHuman: () => f.caps.human });
|
||||||
|
t.after(() => server.close());
|
||||||
|
const request = JSON.stringify({ human: { verb: 'library.file.save' }, verb: 'library.doc.write',
|
||||||
|
args: { ...args(), content: 'x'.repeat(70000) } }) + '\n';
|
||||||
|
const response = await new Promise((resolve, reject) => {
|
||||||
|
const socket = connect(path); let result = '';
|
||||||
|
socket.on('error', reject); socket.on('connect', () => socket.write(request));
|
||||||
|
socket.on('data', bytes => { result += bytes; if (result.includes('\n')) { socket.destroy(); resolve(JSON.parse(result)); } });
|
||||||
|
});
|
||||||
|
assert.deepEqual(response, { ok: false, error: 'request-too-large' });
|
||||||
|
assert.equal(f.seen.length, 0);
|
||||||
|
});
|
||||||
@@ -263,3 +263,47 @@ node --test packages/library/tests/
|
|||||||
|
|
||||||
Fixtures are made-up repos built per test in a temp dir. A marker string stands in for her text, and
|
Fixtures are made-up repos built per test in a temp dir. A marker string stands in for her text, and
|
||||||
the survey tests check that it never appears in output.
|
the survey tests check that it never appears in output.
|
||||||
|
|
||||||
|
### C2-02 supporting writes and human saves
|
||||||
|
|
||||||
|
The host exposes `library.doc.write {universe,path,base,content}` and
|
||||||
|
`library.doc.delete {universe,path,base}` only to a creative session holding
|
||||||
|
`library.write` and a live, unheld Gitea grant for the library project. The
|
||||||
|
broker chooses the project from the universe mapping, the author/committer
|
||||||
|
from the instance identity, and the credential from its grant store. `base`
|
||||||
|
is the served-tree blob id, or `null` to create. Supporting-path checks run
|
||||||
|
before remote effects and against each fetched tip; deletes require an
|
||||||
|
existing `100644` blob. Content is UTF-8 without NUL and at most 1 MiB;
|
||||||
|
agent requests still have the ordinary 64 KiB socket envelope limit.
|
||||||
|
|
||||||
|
Each operation is one plumbing commit with one parent, a fixed
|
||||||
|
`Mosaic: write <path>` or `Mosaic: delete <path>` message, and the trailer
|
||||||
|
`Mosaic-Agent: <business>/<instance>`. Pushes are fast-forward only. A moved
|
||||||
|
remote tip retries once when the target blob still equals `base`; otherwise
|
||||||
|
it refuses `stale-base`. A failed push leaves no queued work or local ref
|
||||||
|
change. A successful push updates served/tracking, rebuilds the index and
|
||||||
|
records `lastPush: {commit,at}`. The broker inserts the exact fixed notice
|
||||||
|
`{notice:"library-write",op,path,commit}` from the writer to the human in
|
||||||
|
that thread, independently of the agent-message citation guard.
|
||||||
|
|
||||||
|
`library.file.save {project,path,base,content}` and
|
||||||
|
`library.revert {project,commit}` are human-only. Project is the business
|
||||||
|
project key. A save reaches any mapped `.md` file under the structural path
|
||||||
|
rules, uses the mapping's `editor` as author and committer, and pushes with
|
||||||
|
slot `library-human`. Revert accepts only a reachable, one-parent,
|
||||||
|
one-path commit with this business's agent trailer, and restores its
|
||||||
|
parent's blob with the agent commit's blob as `base`. The result is
|
||||||
|
`{project,path,reverted,commit}`. Revoking/firing the original writer does
|
||||||
|
not prevent a human revert. Missing editor refuses
|
||||||
|
`library-mapping-invalid`; missing/unsafe human slot refuses
|
||||||
|
`library-human-missing`, before Git. An agent calling either human verb
|
||||||
|
gets `human-required`. Only `library.file.save` joins the larger Console
|
||||||
|
save envelope exception; agent writes retain 64 KiB.
|
||||||
|
|
||||||
|
The write guard excludes concurrent sync and admits a race fetch only for
|
||||||
|
its opaque owner token. The library flock spans fetch/build/push as the
|
||||||
|
brief requires, so readers can receive `library-sync-busy` after the
|
||||||
|
existing five-second lock wait while network Git runs (MVP tradeoff,
|
||||||
|
sage-3628). Publication errors return `outcome-unknown`: served is restored
|
||||||
|
to match old state unless the new state was already published. The remote
|
||||||
|
commit remains; callers must inspect status rather than retry blindly.
|
||||||
@@ -269,10 +269,26 @@ export async function publishLibraryWrite(mirror, { mapping, commit, previous, r
|
|||||||
const tracking = `refs/remotes/library/${mapping.branch}`;
|
const tracking = `refs/remotes/library/${mapping.branch}`;
|
||||||
// The remote push already succeeded. A local publication failure is outcome-unknown to the
|
// The remote push already succeeded. A local publication failure is outcome-unknown to the
|
||||||
// caller; it must never encourage an automatic second push.
|
// caller; it must never encourage an automatic second push.
|
||||||
|
let moved = false;
|
||||||
try {
|
try {
|
||||||
await git(mirror, ['update-ref', 'refs/heads/served', commit, previous ?? ZERO]);
|
await git(mirror, ['update-ref', 'refs/heads/served', commit, previous ?? ZERO]);
|
||||||
|
moved = true;
|
||||||
await git(mirror, ['update-ref', tracking, commit]);
|
await git(mirror, ['update-ref', tracking, commit]);
|
||||||
writeState(mirror, next);
|
writeState(mirror, next);
|
||||||
|
} catch {
|
||||||
|
if (moved) {
|
||||||
|
// A rename may have succeeded before directory fsync failed. Keep the new ref when the
|
||||||
|
// validated state already matches it; otherwise restore served by compare-and-swap.
|
||||||
|
let published = false;
|
||||||
|
try { published = (await readSyncState({ branch: mapping.branch })).served === commit; } catch {}
|
||||||
|
if (!published) {
|
||||||
|
try {
|
||||||
|
await git(mirror, ['update-ref', ...(previous === null
|
||||||
|
? ['-d', 'refs/heads/served', commit] : ['refs/heads/served', previous, commit])]);
|
||||||
} catch { fail('outcome-unknown'); }
|
} catch { fail('outcome-unknown'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail('outcome-unknown');
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ import { classify } from './scope.mjs';
|
|||||||
import { supportingPath } from './supporting.mjs';
|
import { supportingPath } from './supporting.mjs';
|
||||||
import { LibraryError } from './problems.mjs';
|
import { LibraryError } from './problems.mjs';
|
||||||
import { listTree, MAX_BLOB } from './reader.mjs';
|
import { listTree, MAX_BLOB } from './reader.mjs';
|
||||||
import { mirrorPath, ensureMirror, servedCommit, withLibraryLock } from './mirror.mjs';
|
import { mirrorPath, ensureMirror, withLibraryLock } from './mirror.mjs';
|
||||||
import { syncCommand, push, withLibraryWriteGuard, publishLibraryWrite } from './sync.mjs';
|
import { syncCommand, push, withLibraryWriteGuard, publishLibraryWrite } from './sync.mjs';
|
||||||
|
|
||||||
const SHA = /^[a-f0-9]{40}$/;
|
const SHA = /^[a-f0-9]{40}$/;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Shared scratch bare-repository fixture for row 288 library and broker tests.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { repo, git, mappingEntry, BASE } from './helpers.mjs';
|
||||||
|
import { sync, syncCommand } from '../src/sync.mjs';
|
||||||
|
import { mirrorPath, readSyncState } from '../src/mirror.mjs';
|
||||||
|
|
||||||
|
export const PATH = 'Book One/Notes/Agent.md';
|
||||||
|
export const IDENTITY = { name: 'Bean Fixture', email: '[email protected]' };
|
||||||
|
export const EDITOR = { name: 'Human Fixture', email: '[email protected]' };
|
||||||
|
|
||||||
|
export async function world(t) {
|
||||||
|
const f = repo(t, { ...BASE, [PATH]: 'before\n',
|
||||||
|
'Book One/Notes/Executable.md': { text: 'exec\n', mode: 0o755 },
|
||||||
|
'Book One/Notes/Link.md': { link: 'Agent.md' } });
|
||||||
|
git(f.repo, 'update-index', '--add', '--cacheinfo', '160000', f.commit, 'Book One/Notes/Submodule.md');
|
||||||
|
git(f.repo, 'commit', '-qm', 'gitlink fixture');
|
||||||
|
const remote = join(f.root, 'remote.git');
|
||||||
|
git(f.root, 'clone', '-q', '--bare', '--no-local', f.repo, remote);
|
||||||
|
const dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
|
||||||
|
const entry = { ...mappingEntry(), user: 'human-fixture', editor: EDITOR };
|
||||||
|
entry.universes.harbor.supporting = ['Book One/Notes/'];
|
||||||
|
const context = { business: { id: 'acme', projects: { books: entry } }, project: 'books', dataRoot };
|
||||||
|
const file = join(f.root, 'fixture-slot'); writeFileSync(file, 'fixture-write-token-288\n', { mode: 0o600 });
|
||||||
|
const credential = { kind: 'seat', user: 'bean-fixture', file };
|
||||||
|
// Keep the production refspec exactly: a '+' mutant on push would overwrite the racing remote.
|
||||||
|
let beforePush = null, reject = false;
|
||||||
|
const commands = [];
|
||||||
|
const command = (kind, url, opts) => {
|
||||||
|
commands.push(kind);
|
||||||
|
if (kind === 'push' && beforePush) { const fn = beforePush; beforePush = null; fn(); }
|
||||||
|
const c = syncCommand(kind, url, opts);
|
||||||
|
const argv = [...c.argv];
|
||||||
|
argv.splice(0, 0, '-c', 'protocol.file.allow=always');
|
||||||
|
const at = argv.indexOf(url);
|
||||||
|
assert.ok(at >= 0);
|
||||||
|
argv[at] = kind === 'push' && reject ? join(f.root, 'missing.git') : remote;
|
||||||
|
return { argv, env: c.env };
|
||||||
|
};
|
||||||
|
await sync(context, { credential, command });
|
||||||
|
const mirror = mirrorPath(context);
|
||||||
|
const refs = () => git(mirror, 'for-each-ref', '--format=%(refname) %(objectname)');
|
||||||
|
const remoteHead = () => git(remote, 'rev-parse', 'main');
|
||||||
|
const state = () => readSyncState(mirror, { branch: 'main' });
|
||||||
|
const external = (path, content) => {
|
||||||
|
writeFileSync(join(f.repo, path), content); git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'external');
|
||||||
|
git(f.repo, 'push', '-q', remote, 'HEAD:main');
|
||||||
|
};
|
||||||
|
const args = (extra = {}) => ({ ...context, universe: 'harbor', path: PATH,
|
||||||
|
base: git(mirror, 'rev-parse', `served:${PATH}`), content: 'after\n', ...extra });
|
||||||
|
const opts = { credential, identity: IDENTITY, agent: 'acme/bean', command };
|
||||||
|
return { ...f, remote, context, entry, file, credential, command, mirror, refs, remoteHead, state, external, args, opts,
|
||||||
|
race: fn => { beforePush = fn; }, reject: () => { reject = true; }, commands };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,62 +1,15 @@
|
|||||||
// Row 288: real bare-repository plumbing and remote effects. All text and credentials are fixtures.
|
// Row 288: real bare-repository plumbing and remote effects. All text and credentials are fixtures.
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
import { writeFileSync, existsSync, renameSync, mkdirSync, rmSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { repo, git, mappingEntry, BASE } from './helpers.mjs';
|
import { git } from './helpers.mjs';
|
||||||
import { SAFE } from '../../project/src/git.mjs';
|
import { sync, push, withLibraryWriteGuard } from '../src/sync.mjs';
|
||||||
import { gitEnv } from '../../seat/src/checkout.mjs';
|
import { servedCommit, withLibraryLock, readSyncState, SYNC_STATE_FILE } from '../src/mirror.mjs';
|
||||||
import { sync, syncCommand, push, withLibraryWriteGuard } from '../src/sync.mjs';
|
import { publishLibraryWrite } from '../src/sync.mjs';
|
||||||
import { mirrorPath, readSyncState, servedCommit } from '../src/mirror.mjs';
|
import { checkMapping } from '../src/mapping.mjs';
|
||||||
import { writeLibraryDoc, saveLibraryFile, revertLibraryCommit } from '../src/write.mjs';
|
import { writeLibraryDoc, saveLibraryFile, revertLibraryCommit } from '../src/write.mjs';
|
||||||
|
import { world, PATH, EDITOR } from './write-fixture.mjs';
|
||||||
const PATH = 'Book One/Notes/Agent.md';
|
|
||||||
const IDENTITY = { name: 'Bean Fixture', email: '[email protected]' };
|
|
||||||
const EDITOR = { name: 'Human Fixture', email: '[email protected]' };
|
|
||||||
|
|
||||||
async function world(t, options = {}) {
|
|
||||||
const f = repo(t, { ...BASE, [PATH]: 'before\n',
|
|
||||||
'Book One/Notes/Executable.md': { text: 'exec\n', mode: 0o755 },
|
|
||||||
'Book One/Notes/Link.md': { link: 'Agent.md' } });
|
|
||||||
git(f.repo, 'update-index', '--add', '--cacheinfo', '160000', f.commit, 'Book One/Notes/Submodule.md');
|
|
||||||
git(f.repo, 'commit', '-qm', 'gitlink fixture');
|
|
||||||
const remote = join(f.root, 'remote.git');
|
|
||||||
git(f.root, 'clone', '-q', '--bare', '--no-local', f.repo, remote);
|
|
||||||
const dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
|
|
||||||
const entry = { ...mappingEntry(), user: 'human-fixture', editor: EDITOR };
|
|
||||||
entry.universes.harbor.supporting = ['Book One/Notes/'];
|
|
||||||
const context = { business: { id: 'acme', projects: { books: entry } }, project: 'books', dataRoot };
|
|
||||||
const file = join(f.root, 'fixture-slot'); writeFileSync(file, 'fixture-write-token-288\n', { mode: 0o600 });
|
|
||||||
const credential = { kind: 'seat', user: 'bean-fixture', file };
|
|
||||||
// Keep the production refspec exactly: a '+' mutant on push would overwrite the racing remote.
|
|
||||||
let beforePush = null, reject = false;
|
|
||||||
const commands = [];
|
|
||||||
const command = (kind, url, opts) => {
|
|
||||||
commands.push(kind);
|
|
||||||
if (kind === 'push' && beforePush) { const fn = beforePush; beforePush = null; fn(); }
|
|
||||||
const c = syncCommand(kind, url, opts);
|
|
||||||
const argv = [...c.argv];
|
|
||||||
argv.splice(0, 0, '-c', 'protocol.file.allow=always');
|
|
||||||
const at = argv.indexOf(url);
|
|
||||||
assert.ok(at >= 0);
|
|
||||||
argv[at] = kind === 'push' && reject ? join(f.root, 'missing.git') : remote;
|
|
||||||
return { argv, env: c.env };
|
|
||||||
};
|
|
||||||
await sync(context, { credential, command });
|
|
||||||
const mirror = mirrorPath(context);
|
|
||||||
const refs = () => git(mirror, 'for-each-ref', '--format=%(refname) %(objectname)');
|
|
||||||
const remoteHead = () => git(remote, 'rev-parse', 'main');
|
|
||||||
const state = () => readSyncState(mirror, { branch: 'main' });
|
|
||||||
const external = (path, content) => {
|
|
||||||
writeFileSync(join(f.repo, path), content); git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'external');
|
|
||||||
git(f.repo, 'push', '-q', remote, 'HEAD:main');
|
|
||||||
};
|
|
||||||
const args = (extra = {}) => ({ ...context, universe: 'harbor', path: PATH,
|
|
||||||
base: git(mirror, 'rev-parse', `served:${PATH}`), content: 'after\n', ...extra });
|
|
||||||
const opts = { credential, identity: IDENTITY, agent: 'acme/bean', command };
|
|
||||||
return { ...f, remote, context, entry, file, credential, command, mirror, refs, remoteHead, state, external, args, opts,
|
|
||||||
race: fn => { beforePush = fn; }, reject: () => { reject = true; }, commands };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('C2-02: write/delete each publish one parent, seat ident, fixed trailer, index and lastPush', async t => {
|
test('C2-02: write/delete each publish one parent, seat ident, fixed trailer, index and lastPush', async t => {
|
||||||
const f = await world(t), parent = f.remoteHead();
|
const f = await world(t), parent = f.remoteHead();
|
||||||
@@ -157,11 +110,14 @@ test('C2-02: invalid content/editor/human credential refuse before any git comma
|
|||||||
for (const content of ['nul\0', '\ud800', 'x'.repeat(1024 * 1024 + 1)])
|
for (const content of ['nul\0', '\ud800', 'x'.repeat(1024 * 1024 + 1)])
|
||||||
await assert.rejects(writeLibraryDoc(f.args({ content }), f.opts), { code: 'invalid-request' });
|
await assert.rejects(writeLibraryDoc(f.args({ content }), f.opts), { code: 'invalid-request' });
|
||||||
const args = { ...f.context, path: PATH, base: null, content: 'x' };
|
const args = { ...f.context, path: PATH, base: null, content: 'x' };
|
||||||
|
writeFileSync(join(f.mirror, 'unexpected'), 'any local git inspection must refuse\n');
|
||||||
delete f.entry.editor;
|
delete f.entry.editor;
|
||||||
await assert.rejects(saveLibraryFile(args, { credential: { ...f.credential, kind: 'human' }, command: f.command }), { code: 'library-mapping-invalid' });
|
await assert.rejects(saveLibraryFile(args, { credential: { ...f.credential, kind: 'human' }, command: f.command }), { code: 'library-mapping-invalid' });
|
||||||
|
await assert.rejects(revertLibraryCommit({ ...f.context, commit: f.commit }, { credential: { ...f.credential, kind: 'human' }, command: f.command }), { code: 'library-mapping-invalid' });
|
||||||
f.entry.editor = EDITOR;
|
f.entry.editor = EDITOR;
|
||||||
const n = f.commands.length;
|
const n = f.commands.length;
|
||||||
await assert.rejects(saveLibraryFile(args, { credential: { kind: 'human', user: 'human', file: '/absent' }, command: f.command }), { code: 'library-human-missing' });
|
await assert.rejects(saveLibraryFile(args, { credential: { kind: 'human', user: 'human', file: '/absent' }, command: f.command }), { code: 'library-human-missing' });
|
||||||
|
await assert.rejects(revertLibraryCommit({ ...f.context, commit: f.commit }, { credential: { kind: 'human', user: 'human', file: '/absent' }, command: f.command }), { code: 'library-human-missing' });
|
||||||
assert.equal(f.commands.length, n); assert.equal(f.refs(), before);
|
assert.equal(f.commands.length, n); assert.equal(f.refs(), before);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,3 +133,25 @@ test('C2-02 guard: only the exact owning token permits a push race fetch; sync r
|
|||||||
assert.equal(raced.raced, true); assert.equal(raced.tip, f.remoteHead());
|
assert.equal(raced.raced, true); assert.equal(raced.tip, f.remoteHead());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('C2-02 publication F1: failed state rename restores served to match prior state', async t => {
|
||||||
|
const f = await world(t), previous = await servedCommit(f.mirror);
|
||||||
|
const commit = git(f.mirror, 'commit-tree', git(f.mirror, 'rev-parse', `${previous}^{tree}`), '-p', previous, '-m', 'candidate');
|
||||||
|
const statePath = join(f.mirror, SYNC_STATE_FILE), backup = join(f.root, 'state-backup');
|
||||||
|
await withLibraryLock(f.mirror, async lock => {
|
||||||
|
// A filesystem failure after the locked state read, before state publication. The original
|
||||||
|
// state is saved outside the mirror and restored after the failed rename.
|
||||||
|
const read = async options => {
|
||||||
|
const state = await lock.readSyncState(options);
|
||||||
|
renameSync(statePath, backup); mkdirSync(statePath, { mode: 0o700 });
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await assert.rejects(publishLibraryWrite(f.mirror, { mapping: checkMapping(f.entry), commit, previous, readSyncState: read }),
|
||||||
|
{ code: 'outcome-unknown' });
|
||||||
|
assert.equal(git(f.mirror, 'rev-parse', 'served'), previous);
|
||||||
|
} finally { rmSync(statePath, { recursive: true, force: true }); renameSync(backup, statePath); }
|
||||||
|
});
|
||||||
|
assert.equal((await readSyncState(f.mirror, { branch: 'main' })).served, previous);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user