Candidate c9aa9c2ee (megavolt, manifest c2121b55, 15 paths), approved by
Dewey (rev 2966, comment 30138). Gate on Sage's slot by Megavolt,
20:08:37Z to 20:11:33Z: library 77/77, bus 609/609, webui library-cli 1/1
and pinned-copy 10/10, cli stack-copy 17/17, every scripts/test-*.sh rc=0,
fixture tokens 0 hits; mutant kills carried from 4a0e0ab3a with equal blobs.
307 lines
17 KiB
JavaScript
307 lines
17 KiB
JavaScript
// C2-03a: fetch-only sync (design 7.2). Her remote is row 199's fake Gitea on 127.0.0.1 with a
|
|
// scratch CA; the test reaches it only through the `command` parameter, which keeps syncCommand's
|
|
// argv and adds the CA to its env. The token is made up and must appear nowhere but the slot.
|
|
import test, { before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { accessSync, constants, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { scratchCa, fakeGitea } from '../../seat/tests/fake-gitea.mjs';
|
|
import { SAFE } from '../../project/src/git.mjs';
|
|
import { mappingEntry, repo, git, BASE } from './helpers.mjs';
|
|
import { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from '../src/sync.mjs';
|
|
import { mirrorPath, readSyncState, readLibraryFile, SYNC_STATE_FILE } from '../src/mirror.mjs';
|
|
import { readLibrary } from '../src/reader.mjs';
|
|
import { checkMapping } from '../src/mapping.mjs';
|
|
import { survey } from '../src/survey.mjs';
|
|
|
|
const TOKEN = 'fixture-token-5d1c-made-up-0042';
|
|
const USER = 'books-bot';
|
|
const scratch = mkdtempSync(join(tmpdir(), 'library-sync-ca-'));
|
|
let tls;
|
|
before(() => { tls = scratchCa(join(scratch, 'ca')); });
|
|
after(() => rmSync(scratch, { recursive: true, force: true }));
|
|
|
|
const clock = () => { let n = 0; return () => new Date(Date.UTC(2026, 9, 21, 12, 0, n++)); };
|
|
|
|
async function world(t, { token = TOKEN, redirect = null, user = USER } = {}) {
|
|
const f = repo(t);
|
|
const gitea = await fakeGitea({ dir: join(f.root, 'gitea'), tls, user: USER, token, redirect });
|
|
t.after(() => gitea.close());
|
|
const remote = gitea.repo('writer', 'books');
|
|
mkdirSync(dirname(remote), { recursive: true });
|
|
git(f.root, 'clone', '-q', '--bare', '--no-local', f.repo, remote);
|
|
const dataRoot = join(f.root, 'data');
|
|
mkdirSync(dataRoot, { mode: 0o700 });
|
|
const slot = join(f.root, 'config', 'secrets', 'acme', SLOT);
|
|
mkdirSync(dirname(slot), { recursive: true, mode: 0o700 });
|
|
writeFileSync(slot, TOKEN + '\n', { mode: 0o600 });
|
|
const entry = { ...mappingEntry(), remote: `${gitea.origin}/writer/books.git`, ...(user === null ? {} : { user }) };
|
|
const context = { business: { id: 'acme', projects: { books: entry } }, project: 'books', dataRoot };
|
|
const command = (kind, url, opts) => { const c = syncCommand(kind, url, opts); return { argv: c.argv, env: { ...c.env, GIT_SSL_CAINFO: tls.ca } }; };
|
|
const now = clock();
|
|
const run = (options = {}) => sync(context, { slot, command, now, ...options });
|
|
// A commit in the work repo, pushed to the fake remote; `from` rewinds first, for a divergence.
|
|
const push = (files, { from = null } = {}) => {
|
|
if (from) git(f.repo, 'reset', '-q', '--hard', from);
|
|
for (const [path, text] of Object.entries(files)) {
|
|
const file = join(f.repo, path);
|
|
if (text === null) unlinkSync(file);
|
|
else { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, text); }
|
|
}
|
|
git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'next');
|
|
git(f.repo, 'push', '-q', '--force', remote, 'HEAD:refs/heads/main');
|
|
return git(f.repo, 'rev-parse', 'HEAD');
|
|
};
|
|
const mirror = () => mirrorPath(context);
|
|
const state = () => readSyncState(mirror(), { branch: 'main' });
|
|
return { ...f, gitea, remote, dataRoot, slot, entry, context, command, run, push, mirror, state };
|
|
}
|
|
|
|
// Every file under `dir`, with the token searched as bytes.
|
|
function holdsToken(dir) {
|
|
for (const name of readdirSync(dir, { recursive: true })) {
|
|
const file = join(dir, name);
|
|
if (statSync(file).isFile() && readFileSync(file).includes(TOKEN)) return file;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
test('C2-03a argv: the fixed sequence, one https allow, no other protocol key', () => {
|
|
const opts = { mirror: '/srv/data/projects/acme/books', branch: 'main', user: USER, slot: '/srv/config/secrets/acme/library-gitea' };
|
|
const { argv, env } = syncCommand('fetch', 'https://git.example.invalid/writer/books.git', opts);
|
|
assert.deepEqual(argv, [...SAFE, '-c', 'protocol.https.allow=always', '-c', 'http.followRedirects=false', '-c', 'credential.helper=',
|
|
'-c', 'core.createObject=rename', '-C', opts.mirror, 'fetch', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', '--end-of-options',
|
|
'https://git.example.invalid/writer/books.git', '+refs/heads/main:refs/remotes/library/main']);
|
|
const protocol = argv.filter((a, i) => argv[i - 1] === '-c' && a.startsWith('protocol.'));
|
|
assert.deepEqual(protocol, ['protocol.allow=never', 'protocol.https.allow=always']);
|
|
assert.equal(argv.includes('--prune'), false, 'no fetch drops objects a reader may hold (Filbert N2)');
|
|
assert.equal(argv.some(a => /^(gc|prune|repack)$/.test(a)), false);
|
|
assert.equal(SYNC_INTERVAL_MS, 60_000);
|
|
// gosalyn-0068: LC_ALL=C. The helper sits outside the mirror, and the env names the slot path only.
|
|
assert.equal(env.LC_ALL, 'C');
|
|
assert.equal(env.GIT_ASKPASS, ASKPASS);
|
|
assert.equal(ASKPASS.startsWith(opts.mirror), false);
|
|
accessSync(ASKPASS, constants.X_OK);
|
|
assert.deepEqual(Object.keys(env).sort(), ['GIT_ASKPASS', 'GIT_CEILING_DIRECTORIES', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_NOSYSTEM',
|
|
'GIT_MERGE_AUTOEDIT', 'GIT_TERMINAL_PROMPT', 'HOME', 'LC_ALL', 'MOSAIC_LIBRARY_HOST', 'MOSAIC_LIBRARY_SLOT', 'MOSAIC_LIBRARY_USER', 'PATH']);
|
|
assert.equal(env.MOSAIC_LIBRARY_SLOT, opts.slot);
|
|
assert.equal(env.MOSAIC_LIBRARY_HOST, 'git.example.invalid');
|
|
assert.equal(syncCommand('fetch', 'https://127.0.0.1:4430/w/b.git', opts).env.MOSAIC_LIBRARY_HOST, '127.0.0.1:4430');
|
|
});
|
|
|
|
test('C2-03a argv: only an https URL with a plain host, no userinfo, and a plain user', () => {
|
|
const opts = { mirror: '/m', branch: 'main', user: USER, slot: '/s' };
|
|
for (const url of ['http://git.example.invalid/b.git', 'ssh://git.example.invalid/b.git', 'file:///srv/b.git', 'ext::sh -c x',
|
|
'https://[email protected]/b.git', 'https://bot:[email protected]/b.git', 'https://git.example.invalid/b.git?x=1',
|
|
'https://git.example.invalid/b.git#x', 'https://[::1]/b.git', 'not a url'])
|
|
assert.throws(() => syncCommand('fetch', url, opts), { code: 'library-remote-invalid' }, url);
|
|
for (const user of [undefined, '', 'a b', 'bot@x', 'bot:x', 'a'.repeat(101)])
|
|
assert.throws(() => syncCommand('fetch', 'https://git.example.invalid/b.git', { ...opts, user }), { code: 'library-remote-invalid' });
|
|
assert.throws(() => syncCommand('push', 'https://git.example.invalid/b.git', opts), { code: 'library-remote-invalid' });
|
|
});
|
|
|
|
test('C2-03a helper: answers only the exact prompts for the configured host and user (dewey-0765)', t => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'library-askpass-'));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
const slot = join(dir, 'slot');
|
|
writeFileSync(slot, TOKEN + '\nsecond line\n', { mode: 0o600 });
|
|
const env = { PATH: '/usr/bin:/bin', MOSAIC_LIBRARY_HOST: '127.0.0.1:4430', MOSAIC_LIBRARY_USER: USER, MOSAIC_LIBRARY_SLOT: slot };
|
|
const ask = (prompt, change = {}) => spawnSync(ASKPASS, [prompt], { env: { ...env, ...change }, encoding: 'utf8' });
|
|
const user = ask(`Username for 'https://127.0.0.1:4430': `);
|
|
assert.equal(user.status, 0); assert.equal(user.stdout, USER + '\n');
|
|
assert.equal(user.stdout.includes(TOKEN), false, 'the Username answer is never the token');
|
|
const pass = ask(`Password for 'https://${USER}@127.0.0.1:4430': `);
|
|
assert.equal(pass.status, 0); assert.equal(pass.stdout, TOKEN + '\n', 'the first line of the slot only');
|
|
for (const prompt of [
|
|
`Username for 'https://other.example.invalid': `, `Password for 'https://${USER}@other.example.invalid': `,
|
|
`Username for 'https://127.0.0.1:4431': `, `Password for 'https://${USER}@127.0.0.1:4431': `, `Username for 'https://127.0.0.1': `,
|
|
`Password for 'https://[email protected]:4430': `, `Password for 'https://127.0.0.1:4430': `,
|
|
`Username for 'http://127.0.0.1:4430': `, `Username for 'https://127.0.0.1:4430':`, 'Are you sure you want to continue connecting (yes/no)? ', '']) {
|
|
const r = ask(prompt);
|
|
assert.equal(r.status, 1, prompt); assert.equal(r.stdout, '', prompt);
|
|
}
|
|
for (const change of [{ MOSAIC_LIBRARY_SLOT: '' }, { MOSAIC_LIBRARY_USER: '' }, { MOSAIC_LIBRARY_HOST: '' },
|
|
{ MOSAIC_LIBRARY_SLOT: join(dir, 'absent') }, { MOSAIC_LIBRARY_USER: 'bot@x' }]) {
|
|
const r = ask(`Password for 'https://${change.MOSAIC_LIBRARY_USER ?? USER}@127.0.0.1:4430': `, change);
|
|
assert.equal(r.status, 1); assert.equal(r.stdout, '');
|
|
}
|
|
const link = join(dir, 'link'); symlinkSync(slot, link);
|
|
assert.equal(ask(`Password for 'https://${USER}@127.0.0.1:4430': `, { MOSAIC_LIBRARY_SLOT: link }).status, 1);
|
|
writeFileSync(slot, '\n', { mode: 0o600 });
|
|
assert.equal(ask(`Password for 'https://${USER}@127.0.0.1:4430': `).status, 1, 'an empty first line is no token');
|
|
});
|
|
|
|
test('C2-03a first sync: served is the fetched tip, HEAD is served, the survey reads it, and the token stays in the slot', async t => {
|
|
const w = await world(t);
|
|
const { state, code } = await w.run();
|
|
assert.equal(code, null);
|
|
assert.equal(state.served, w.commit);
|
|
assert.equal(git(w.mirror(), 'rev-parse', 'refs/heads/served'), w.commit);
|
|
assert.equal(git(w.mirror(), 'symbolic-ref', 'HEAD'), 'refs/heads/served');
|
|
assert.equal((await survey({ repo: w.mirror() })).commit, state.served);
|
|
assert.deepEqual(await w.state(), state);
|
|
assert.equal(state.lastFetch, '2026-10-21T12:00:00.000Z');
|
|
assert.equal(state.lastError, null);
|
|
assert.ok(w.gitea.requests.some(r => r.user === USER), 'the configured user answered the prompt');
|
|
assert.ok(w.gitea.requests.every(r => r.user === null || r.user === USER));
|
|
// R-problems are recorded without holding the head: BASE has an unmapped top-level note.
|
|
assert.ok(state.problems.some(p => p.code === 'R2' && p.path === 'Inbox Note.md'));
|
|
assert.equal(git(w.mirror(), 'remote'), '');
|
|
assert.equal(git(w.mirror(), 'config', '--list', '--local').includes(TOKEN), false);
|
|
assert.equal(holdsToken(w.dataRoot), null);
|
|
assert.equal(JSON.stringify(syncCommand('fetch', w.entry.remote, { mirror: w.mirror(), branch: 'main', user: USER, slot: w.slot })).includes(TOKEN), false);
|
|
assert.deepEqual(readdirSync(dirname(w.mirror())), ['books'], 'no temp state file is left beside the mirror');
|
|
assert.equal(statSync(join(w.mirror(), SYNC_STATE_FILE)).mode & 0o777, 0o600);
|
|
});
|
|
|
|
test('C2-03a fast-forward: served moves, problems are rebuilt, and a repeat changes only lastFetch', async t => {
|
|
const w = await world(t);
|
|
await w.run();
|
|
const next = w.push({ 'Inbox Note.md': null, 'Loose.md': 'made up\n' });
|
|
const { state, code } = await w.run();
|
|
assert.equal(code, null);
|
|
assert.equal(state.served, next);
|
|
assert.ok(state.problems.some(p => p.path === 'Loose.md'));
|
|
assert.equal(state.problems.some(p => p.path === 'Inbox Note.md'), false);
|
|
const again = await w.run();
|
|
assert.deepEqual({ ...again.state, lastFetch: null }, { ...state, lastFetch: null });
|
|
assert.notEqual(again.state.lastFetch, state.lastFetch);
|
|
});
|
|
|
|
test('C2-03a Filbert N2: a read that took served before a sync still finds its objects after served moves', async t => {
|
|
const w = await world(t);
|
|
await w.run();
|
|
const held = (await w.state()).served;
|
|
const blob = git(w.mirror(), 'rev-parse', `${held}:Book One/Characters/Tomas.md`);
|
|
const next = w.push({ 'Book One/Characters/Tomas.md': null });
|
|
assert.equal((await w.run()).state.served, next);
|
|
git(w.mirror(), 'cat-file', '-e', blob);
|
|
const read = await readLibrary({ repo: w.mirror(), commit: held, mapping: checkMapping(w.entry) });
|
|
assert.ok(read.files.some(file => file.path === 'Book One/Characters/Tomas.md'));
|
|
await assert.rejects(readLibraryFile({ ...w.context, path: 'Book One/Characters/Tomas.md' }), { code: 'project-read-missing' });
|
|
});
|
|
|
|
test('C2-03a overlap: a reader during a sync sees the old pair or the new one, never a mix', async t => {
|
|
const w = await world(t);
|
|
const first = (await w.run()).state.served;
|
|
const next = w.push({ 'Loose.md': 'made up\n' });
|
|
let done = false;
|
|
const syncing = w.run().finally(() => { done = true; });
|
|
const seen = new Set();
|
|
while (!done) {
|
|
const s = await w.state();
|
|
assert.ok([first, next].includes(s.served));
|
|
assert.equal(s.problems.some(p => p.path === 'Loose.md'), s.served === next);
|
|
seen.add(s.served);
|
|
}
|
|
assert.equal((await syncing).state.served, next);
|
|
assert.ok(seen.has(first));
|
|
});
|
|
|
|
test('C2-03a diverged: a non-fast-forward keeps serving and holds until a human accepts the tip', async t => {
|
|
const w = await world(t);
|
|
await w.run();
|
|
const ahead = w.push({ 'Loose.md': 'made up\n' });
|
|
assert.equal((await w.run()).state.served, ahead);
|
|
const rewritten = w.push({ 'Other.md': 'made up\n' }, { from: w.commit });
|
|
const held = await w.run();
|
|
assert.equal(held.code, 'library-diverged');
|
|
assert.equal(held.state.served, ahead);
|
|
assert.equal(git(w.mirror(), 'rev-parse', 'refs/heads/served'), ahead);
|
|
assert.deepEqual(held.state.diverged, { commit: rewritten, at: held.state.lastFetch });
|
|
// A later fast-forward of served is still held: only --accept moves it.
|
|
const later = w.push({ 'Third.md': 'made up\n' }, { from: ahead });
|
|
const still = await w.run();
|
|
assert.equal(still.code, 'library-diverged');
|
|
assert.equal(still.state.served, ahead);
|
|
assert.deepEqual(still.state.diverged, { commit: later, at: held.state.diverged.at });
|
|
await assert.rejects(w.run({ accept: rewritten }), { code: 'library-accept-stale' });
|
|
await assert.rejects(w.run({ accept: 'not-a-commit' }), { code: 'library-accept-invalid' });
|
|
assert.equal((await w.state()).served, ahead);
|
|
const accepted = await w.run({ accept: later });
|
|
assert.equal(accepted.code, null);
|
|
assert.equal(accepted.state.served, later);
|
|
assert.equal(accepted.state.diverged, null);
|
|
assert.ok(accepted.state.problems.some(p => p.path === 'Third.md'));
|
|
assert.deepEqual(await w.state(), accepted.state);
|
|
});
|
|
|
|
test('C2-03a accept starts the record over, so a changed branch gets a fresh state', async t => {
|
|
const w = await world(t);
|
|
await w.run();
|
|
git(w.remote, 'branch', 'drafts', 'main');
|
|
w.entry.branch = 'drafts';
|
|
await assert.rejects(w.run(), { code: 'library-state-invalid' });
|
|
const accepted = await w.run({ accept: w.commit });
|
|
assert.equal(accepted.state.branch, 'drafts');
|
|
assert.equal(accepted.state.served, w.commit);
|
|
});
|
|
|
|
test('C2-03a fetch failure: a wrong token keeps serving and records the error, lastFetch unchanged', async t => {
|
|
const w = await world(t);
|
|
const good = (await w.run()).state;
|
|
writeFileSync(w.slot, 'made-up-wrong-token\n', { mode: 0o600 });
|
|
w.push({ 'Loose.md': 'made up\n' });
|
|
const { state, code } = await w.run();
|
|
assert.equal(code, 'library-fetch-failed');
|
|
assert.equal(state.served, good.served);
|
|
assert.equal(state.lastFetch, good.lastFetch);
|
|
assert.equal(state.lastError.code, 'library-fetch-failed');
|
|
assert.deepEqual(state.problems, good.problems);
|
|
assert.deepEqual(await w.state(), state);
|
|
writeFileSync(w.slot, TOKEN + '\n', { mode: 0o600 });
|
|
assert.equal((await w.run()).state.lastError, null, 'the next good fetch clears it');
|
|
});
|
|
|
|
test('C2-03a a failed first fetch records the error with nothing served', async t => {
|
|
const w = await world(t, { token: 'made-up-other-token' });
|
|
const { state, code } = await w.run();
|
|
assert.equal(code, 'library-fetch-failed');
|
|
assert.equal(state.served, null);
|
|
assert.deepEqual(await w.state(), state);
|
|
});
|
|
|
|
test('C2-03a a 302 to another host fails the fetch and the other host sees nothing', async t => {
|
|
const f = repo(t);
|
|
const other = await fakeGitea({ dir: join(f.root, 'other'), tls, user: USER, token: TOKEN });
|
|
t.after(() => other.close());
|
|
const w = await world(t, { redirect: other.origin });
|
|
const { state, code } = await w.run();
|
|
assert.equal(code, 'library-fetch-failed');
|
|
assert.equal(state.served, null);
|
|
assert.ok(w.gitea.requests.length > 0);
|
|
assert.equal(other.requests.length, 0);
|
|
});
|
|
|
|
test('C2-03a the slot: missing or unsafe refuses before git, and sync never opens it', async t => {
|
|
const w = await world(t);
|
|
rmSync(w.slot);
|
|
const missing = await w.run();
|
|
assert.equal(missing.code, 'library-credential-missing');
|
|
assert.equal(missing.state.lastError.code, 'library-credential-missing');
|
|
assert.equal(w.gitea.requests.length, 0);
|
|
writeFileSync(w.slot, TOKEN + '\n', { mode: 0o644 });
|
|
assert.equal((await w.run()).code, 'library-credential-missing', 'a slot others can read is refused');
|
|
assert.equal((await w.run({ slot: null })).code, 'library-credential-missing');
|
|
assert.equal(w.gitea.requests.length, 0);
|
|
});
|
|
|
|
test('C2-03a sage-2021: an entry with no user refuses library-remote-invalid and runs no git', async t => {
|
|
const w = await world(t, { user: null });
|
|
let called = 0;
|
|
await assert.rejects(sync(w.context, { slot: w.slot, command: (...a) => { called++; return w.command(...a); } }), { code: 'library-remote-invalid' });
|
|
assert.equal(called, 0);
|
|
assert.equal(w.gitea.requests.length, 0);
|
|
assert.deepEqual(readdirSync(w.mirror()).includes(SYNC_STATE_FILE), false);
|
|
});
|
|
|
|
test('C2-03a one sync per mirror: an overlapping call refuses library-sync-busy', async t => {
|
|
const w = await world(t);
|
|
const results = await Promise.allSettled([w.run(), w.run()]);
|
|
assert.deepEqual(results.map(r => r.status).sort(), ['fulfilled', 'rejected']);
|
|
assert.equal(results.find(r => r.status === 'rejected').reason.code, 'library-sync-busy');
|
|
assert.equal((await w.run()).code, null, 'the mirror is free again afterwards');
|
|
});
|