(cherry picked from commit367fd0d240) (cherry picked from commit068e1e0f1c)
545 lines
33 KiB
JavaScript
545 lines
33 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, chmodSync, constants, existsSync, 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, push, humanCredential, ASKPASS, SLOT, HUMAN_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, users = { [USER]: token } } = {}) {
|
|
const f = repo(t);
|
|
const gitea = await fakeGitea({ dir: join(f.root, 'gitea'), tls, user: USER, token, users, 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');
|
|
});
|
|
|
|
// C2-03b (#1791): push and per-seat credentials. A seat's grant store file is the token plus "\n", as
|
|
// Credentials writes it. SEAT_TOKEN is made up too and must also appear nowhere but its file.
|
|
const SEAT = 'writer-seat';
|
|
const SEAT_TOKEN = 'fx286-seat-q7-r6-a9';
|
|
|
|
async function writeWorld(t) {
|
|
const w = await world(t, { users: { [USER]: TOKEN, [SEAT]: SEAT_TOKEN } });
|
|
const seatFile = join(w.root, 'store', 'acme', SEAT, 'gitea');
|
|
mkdirSync(dirname(seatFile), { recursive: true, mode: 0o700 });
|
|
writeFileSync(seatFile, SEAT_TOKEN + '\n', { mode: 0o600 });
|
|
const seat = { kind: 'seat', user: SEAT, file: seatFile };
|
|
assert.equal((await w.run()).code, null);
|
|
// A commit on top of `parent` in the mirror, as C2-02's apply would leave it.
|
|
const child = (parent, text = 'next') => git(w.mirror(), 'commit-tree', `${parent}^{tree}`, '-p', parent, '-m', text);
|
|
const remoteTip = () => git(w.remote, 'rev-parse', 'refs/heads/main');
|
|
const write = (options) => push(w.context, { command: w.command, credential: seat, ...options });
|
|
return { ...w, seatFile, seat, child, remoteTip, write };
|
|
}
|
|
|
|
test('C2-03b argv: a push sends one full commit to the branch, fast-forward only, with the fetch prefix', () => {
|
|
const opts = { mirror: '/srv/data/projects/acme/books', branch: 'main', user: SEAT, slot: '/srv/store/acme/writer-seat/gitea' };
|
|
const commit = 'c'.repeat(40);
|
|
const { argv, env } = syncCommand('push', 'https://git.example.invalid/writer/books.git', { ...opts, commit });
|
|
assert.deepEqual(argv, [...SAFE, '-c', 'protocol.https.allow=always', '-c', 'http.followRedirects=false', '-c', 'credential.helper=',
|
|
'-c', 'core.createObject=rename', '-C', opts.mirror, 'push', '--porcelain', '--no-verify', '--no-follow-tags', '--end-of-options',
|
|
'https://git.example.invalid/writer/books.git', `${commit}:refs/heads/main`]);
|
|
assert.equal(argv.some(a => a.startsWith('+') || a === '--force' || a === '-f' || a.startsWith('--force-')), false, 'no forced update (D6)');
|
|
assert.equal(env.MOSAIC_LIBRARY_SLOT, opts.slot);
|
|
assert.equal(env.MOSAIC_LIBRARY_USER, SEAT);
|
|
for (const bad of [null, undefined, 'main', 'c'.repeat(39), 'C'.repeat(40), `${'c'.repeat(40)}:refs/heads/x`])
|
|
assert.throws(() => syncCommand('push', 'https://git.example.invalid/b.git', { ...opts, commit: bad }), { code: 'library-remote-invalid' }, String(bad));
|
|
assert.throws(() => syncCommand('gc', 'https://git.example.invalid/b.git', opts), { code: 'library-remote-invalid' });
|
|
});
|
|
|
|
test('C2-03b T1 push: a fast-forward lands with the seat\'s own user and file, and moves no local ref', async t => {
|
|
const w = await writeWorld(t);
|
|
const before = git(w.mirror(), 'for-each-ref', '--format=%(refname) %(objectname)');
|
|
const next = w.child(w.commit);
|
|
w.gitea.requests.length = 0;
|
|
assert.deepEqual(await w.write({ commit: next }), { pushed: true });
|
|
assert.equal(w.remoteTip(), next);
|
|
assert.ok(w.gitea.requests.length > 0);
|
|
assert.ok(w.gitea.requests.every(r => r.user === null || r.user === SEAT), 'only the seat\'s user reaches the remote');
|
|
assert.ok(w.gitea.requests.some(r => r.user === SEAT));
|
|
assert.equal(git(w.mirror(), 'for-each-ref', '--format=%(refname) %(objectname)'), before, 'C2-02 moves the local refs, not push');
|
|
assert.equal(holdsToken(w.dataRoot), null, 'the bot token sits only in the slot');
|
|
for (const name of readdirSync(w.root, { recursive: true })) {
|
|
const file = join(w.root, name);
|
|
if (file !== w.seatFile && statSync(file).isFile()) assert.equal(readFileSync(file).includes(SEAT_TOKEN), false, file);
|
|
}
|
|
});
|
|
|
|
test('C2-03b T2 and T3: a moved tip reports the race after one fetch, and a second race refuses stale-base', async t => {
|
|
const w = await writeWorld(t);
|
|
const moved = w.push({ 'Inbox Note.md': 'she wrote more\n' });
|
|
const mine = w.child(w.commit);
|
|
const raced = await w.write({ commit: mine });
|
|
assert.deepEqual(raced, { pushed: false, raced: true, tip: moved });
|
|
assert.equal(w.remoteTip(), moved, 'her commit stands: nothing forced it out');
|
|
assert.equal(git(w.mirror(), 'rev-parse', 'refs/remotes/library/main'), moved, 'the one fetch ran with the same credential');
|
|
const again = w.push({ 'Inbox Note.md': 'and more\n' });
|
|
await assert.rejects(w.write({ commit: w.child(moved), retried: true }), { code: 'stale-base' });
|
|
assert.equal(w.remoteTip(), again);
|
|
});
|
|
|
|
// dewey-0864 R1: the race fetch takes sync's guard. A sync holds it: its fetch is a git alias that
|
|
// touches a marker, then waits for a release file. The push starts only once the marker exists and
|
|
// the release comes only after the refusal, so no step depends on timing (dewey-0869).
|
|
test('C2-03b race fetch: while a sync of the mirror runs, the race refuses library-sync-busy', async t => {
|
|
const w = await writeWorld(t);
|
|
const moved = w.push({ 'Inbox Note.md': 'she wrote more\n' });
|
|
const before = git(w.mirror(), 'rev-parse', 'refs/remotes/library/main');
|
|
const mark = join(w.root, 'hold.mark'), release = join(w.root, 'hold.release');
|
|
const hold = '!f() { touch "$LIB_HOLD_MARK"; i=0; while [ ! -e "$LIB_HOLD_RELEASE" ] && [ $i -lt 1200 ]; do sleep 0.05; i=$((i+1)); done; }; f';
|
|
const slow = (kind, url, opts) => {
|
|
const real = w.command(kind, url, opts);
|
|
return { argv: ['-c', 'alias.hold=' + hold, 'hold'], env: { ...real.env, LIB_HOLD_MARK: mark, LIB_HOLD_RELEASE: release } };
|
|
};
|
|
const syncing = sync(w.context, { slot: w.slot, command: slow });
|
|
t.after(() => { try { writeFileSync(release, ''); } catch {} });
|
|
const end = Date.now() + 30000;
|
|
while (!existsSync(mark) && Date.now() < end) await new Promise(r => setTimeout(r, 10));
|
|
assert.ok(existsSync(mark), 'the sync is inside its fetch, holding the guard');
|
|
await assert.rejects(w.write({ commit: w.child(w.commit) }), { code: 'library-sync-busy' });
|
|
assert.equal(w.remoteTip(), moved, 'her commit stands');
|
|
writeFileSync(release, '');
|
|
await syncing.catch(() => {});
|
|
assert.equal(git(w.mirror(), 'rev-parse', 'refs/remotes/library/main'), before, 'the refused race fetched nothing');
|
|
assert.deepEqual(await w.write({ commit: w.child(w.commit) }), { pushed: false, raced: true, tip: moved }, 'free again afterwards');
|
|
});
|
|
|
|
test('C2-03b T4: a dead remote, a hook refusal and a wrong token each refuse library-push-failed', async t => {
|
|
const w = await writeWorld(t);
|
|
// N2: [remote rejected] is a hook, not a race.
|
|
const hook = join(w.remote, 'hooks', 'pre-receive');
|
|
writeFileSync(hook, '#!/bin/sh\necho no >&2\nexit 1\n', { mode: 0o755 });
|
|
const next = w.child(w.commit);
|
|
await assert.rejects(w.write({ commit: next }), { code: 'library-push-failed' });
|
|
await assert.rejects(w.write({ commit: next, retried: true }), { code: 'library-push-failed' }, 'never stale-base for a hook');
|
|
rmSync(hook);
|
|
writeFileSync(w.seatFile, 'fixture-token-wrong-0045\n', { mode: 0o600 });
|
|
await assert.rejects(w.write({ commit: next }), { code: 'library-push-failed' });
|
|
writeFileSync(w.seatFile, SEAT_TOKEN + '\n', { mode: 0o600 });
|
|
await w.gitea.close();
|
|
await assert.rejects(w.write({ commit: next }), { code: 'library-push-failed' });
|
|
assert.equal(w.remoteTip(), w.commit);
|
|
});
|
|
|
|
test('C2-03b push: a refused, missing or unsafe credential runs no git', async t => {
|
|
const w = await writeWorld(t);
|
|
let called = 0;
|
|
const command = (...a) => { called++; return w.command(...a); };
|
|
const next = w.child(w.commit);
|
|
w.gitea.requests.length = 0;
|
|
const cases = [
|
|
[{ refused: 'library-fetch-no-grant' }, 'library-fetch-no-grant'],
|
|
[null, 'library-credential-missing'],
|
|
[{ kind: 'seat', file: w.seatFile }, 'library-credential-missing'],
|
|
[{ kind: 'seat', user: 'a b', file: w.seatFile }, 'library-credential-missing'],
|
|
[{ kind: 'nobody', user: SEAT, file: w.seatFile }, 'library-credential-missing'],
|
|
[{ ...w.seat, file: join(w.root, 'absent') }, 'library-credential-missing'],
|
|
[{ kind: 'human', user: 'her-login', file: join(w.root, 'absent') }, 'library-human-missing'],
|
|
];
|
|
for (const [credential, code] of cases)
|
|
await assert.rejects(push(w.context, { commit: next, credential, command }), { code }, JSON.stringify(credential));
|
|
chmodSync(w.seatFile, 0o644);
|
|
await assert.rejects(push(w.context, { commit: next, credential: w.seat, command }), { code: 'library-credential-missing' });
|
|
chmodSync(w.seatFile, 0o600);
|
|
await assert.rejects(push(w.context, { commit: 'main', credential: w.seat, command }), { code: 'library-remote-invalid' });
|
|
assert.equal(called, 0);
|
|
assert.equal(w.gitea.requests.length, 0);
|
|
});
|
|
|
|
test('C2-03b T10 humanCredential: her login and a 0600 slot library-human, or library-human-missing', t => {
|
|
const configDir = mkdtempSync(join(tmpdir(), 'library-human-'));
|
|
t.after(() => rmSync(configDir, { recursive: true, force: true }));
|
|
const context = { business: { id: 'acme' }, project: 'books' };
|
|
const file = join(configDir, 'secrets', 'acme', HUMAN_SLOT);
|
|
assert.throws(() => humanCredential(context, { user: 'her-login', configDir }), { code: 'library-human-missing' }, 'missing');
|
|
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
writeFileSync(file, 'fixture-token-human-0046\n', { mode: 0o600 });
|
|
assert.deepEqual(humanCredential(context, { user: 'her-login', configDir }), { kind: 'human', user: 'her-login', file });
|
|
for (const user of [undefined, null, '', 'a b', 'her@x'])
|
|
assert.throws(() => humanCredential(context, { user, configDir }), { code: 'library-human-missing' }, String(user));
|
|
assert.throws(() => humanCredential(context, { user: 'her-login' }), { code: 'library-human-missing' });
|
|
chmodSync(file, 0o644);
|
|
assert.throws(() => humanCredential(context, { user: 'her-login', configDir }), { code: 'library-human-missing' }, '0644');
|
|
chmodSync(file, 0o600);
|
|
const real = join(configDir, 'real'); writeFileSync(real, 'x\n', { mode: 0o600 });
|
|
rmSync(file); symlinkSync(real, file);
|
|
assert.throws(() => humanCredential(context, { user: 'her-login', configDir }), { code: 'library-human-missing' }, 'a link');
|
|
});
|
|
|
|
test('C2-03b fetch credential: the state names the kind and user, a refusal runs no git, and a pre-286 state still reads', async t => {
|
|
const w = await world(t, { users: { [USER]: TOKEN, [SEAT]: SEAT_TOKEN } });
|
|
const first = await w.run();
|
|
assert.equal(first.code, null);
|
|
assert.deepEqual(first.state.credential, { kind: 'slot', user: USER });
|
|
assert.deepEqual((await w.state()).credential, { kind: 'slot', user: USER });
|
|
// The chief's grant store file, with the chief's Gitea user.
|
|
const chiefFile = join(w.root, 'store', 'acme', 'chief', 'gitea');
|
|
mkdirSync(dirname(chiefFile), { recursive: true, mode: 0o700 });
|
|
writeFileSync(chiefFile, SEAT_TOKEN + '\n', { mode: 0o600 });
|
|
w.gitea.requests.length = 0;
|
|
const chief = await w.run({ slot: undefined, credential: { kind: 'chief', user: SEAT, file: chiefFile } });
|
|
assert.equal(chief.code, null);
|
|
assert.deepEqual(chief.state.credential, { kind: 'chief', user: SEAT });
|
|
assert.ok(w.gitea.requests.some(r => r.user === SEAT) && w.gitea.requests.every(r => r.user !== USER));
|
|
w.gitea.requests.length = 0;
|
|
let called = 0;
|
|
const refused = await sync(w.context, { credential: { refused: 'library-fetch-no-grant' }, slot: w.slot,
|
|
command: (...a) => { called++; return w.command(...a); }, now: clock() });
|
|
assert.equal(refused.code, 'library-fetch-no-grant');
|
|
assert.equal(refused.state.lastError.code, 'library-fetch-no-grant');
|
|
assert.equal(refused.state.credential, null);
|
|
assert.equal(called, 0);
|
|
assert.equal(w.gitea.requests.length, 0, 'a refusal opens no file and reaches no remote, even with a valid slot');
|
|
// N1: a state file written before C2-03b has no credential key and still reads.
|
|
const file = join(w.mirror(), SYNC_STATE_FILE);
|
|
const { credential, ...old } = JSON.parse(readFileSync(file, 'utf8'));
|
|
assert.equal(credential, null);
|
|
writeFileSync(file, JSON.stringify(old) + '\n');
|
|
assert.deepEqual(await w.state(), old);
|
|
writeFileSync(file, JSON.stringify({ ...old, credential: { kind: 'slot', user: USER, file: w.slot } }) + '\n');
|
|
await assert.rejects(w.state(), { code: 'library-state-invalid' }, 'the state never names a file');
|
|
});
|
|
|
|
// T5 and T5b: the helper on a seat's file. `stat` is the one external command between the open and the
|
|
// read, so a shim on the helper's PATH stands in for another uid and stages a swap.
|
|
test('C2-03b T5 helper: the named seat\'s user and file, never a link, 0644, another uid, or a swapped file', t => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'library-askpass-seat-'));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
const file = join(dir, 'gitea'), other = join(dir, 'other');
|
|
writeFileSync(file, SEAT_TOKEN + '\n', { mode: 0o600 });
|
|
writeFileSync(other, 'fixture-token-other-0047\n', { mode: 0o600 });
|
|
const bin = join(dir, 'bin'); mkdirSync(bin);
|
|
const realStat = spawnSync('sh', ['-c', 'command -v stat'], { encoding: 'utf8' }).stdout.trim();
|
|
const env = { PATH: '/usr/bin:/bin', MOSAIC_LIBRARY_HOST: '127.0.0.1:4430', MOSAIC_LIBRARY_USER: SEAT, MOSAIC_LIBRARY_SLOT: file };
|
|
const prompt = `Password for 'https://${SEAT}@127.0.0.1:4430': `;
|
|
const ask = (change = {}, p = prompt) => spawnSync(ASKPASS, [p], { env: { ...env, ...change }, encoding: 'utf8' });
|
|
assert.equal(ask().stdout, SEAT_TOKEN + '\n');
|
|
assert.equal(ask({}, `Username for 'https://127.0.0.1:4430': `).stdout, SEAT + '\n');
|
|
const refused = (r, why) => { assert.equal(r.status, 1, why); assert.equal(r.stdout, '', why); };
|
|
refused(ask({}, `Password for 'https://${USER}@127.0.0.1:4430': `), 'another seat\'s user');
|
|
const link = join(dir, 'link'); symlinkSync(file, link);
|
|
refused(ask({ MOSAIC_LIBRARY_SLOT: link }), 'a link');
|
|
chmodSync(file, 0o644); refused(ask(), '0644');
|
|
chmodSync(file, 0o640); refused(ask(), '0640');
|
|
chmodSync(file, 0o600);
|
|
writeFileSync(other, 'x'.repeat(16385), { mode: 0o600 });
|
|
refused(ask({ MOSAIC_LIBRARY_SLOT: other }), 'over 16384 bytes');
|
|
writeFileSync(other, 'fixture-token-other-0047\n', { mode: 0o600 });
|
|
// Another uid: this suite can't chown, so the shim reports uid+1 for the open file.
|
|
writeFileSync(join(bin, 'stat'), `#!/bin/sh\ncase $1 in -L) out=$(${realStat} "$@") || exit 1; set -- $out; printf '%s %s %s %s %s %s\\n' "$1" "$(($2 + 1))" "$3" "$4" "$5" "$6" ;; *) exec ${realStat} "$@" ;; esac\n`, { mode: 0o755 });
|
|
refused(ask({ PATH: `${bin}:/usr/bin:/bin` }), 'another uid');
|
|
// T5b, the swap: on its first call the shim renames another file over the slot, then stats.
|
|
const marker = join(dir, 'swapped');
|
|
writeFileSync(join(bin, 'stat'), `#!/bin/sh\nif [ ! -e '${marker}' ]; then : > '${marker}'; mv -f '${other}' '${file}'; fi\nexec ${realStat} "$@"\n`, { mode: 0o755 });
|
|
refused(ask({ PATH: `${bin}:/usr/bin:/bin` }), 'a file swapped between the check and the read');
|
|
assert.ok(statSync(marker).isFile(), 'the swap ran');
|
|
assert.equal(readFileSync(file, 'utf8'), 'fixture-token-other-0047\n', 'the slot path names the other file now');
|
|
});
|
|
|
|
test('C2-15 sage-4389 (a): an excluded-link R6 is written to the sync state with the default message and reads back', async t => {
|
|
const w = await world(t);
|
|
// context.business.projects.books is this same entry, so the run checks the mapping with the hybrid in it.
|
|
w.entry.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'],
|
|
constraints: [{ exclude: 'Book One/Places' }] };
|
|
const next = w.push({ 'Crossover/Chapters/A.md': 'See [[Harbor Town]].\n' });
|
|
const { state, code } = await w.run();
|
|
assert.equal(code, null);
|
|
assert.equal(state.served, next);
|
|
const read = await w.state();
|
|
assert.equal(read.served, next);
|
|
assert.deepEqual(read.problems.filter(p => p.path === 'Crossover/Chapters/A.md'),
|
|
[{ code: 'R6', path: 'Crossover/Chapters/A.md', line: 1, message: 'canon file refers into another universe' }]);
|
|
});
|