diff --git a/back/config/util.ts b/back/config/util.ts index dc8bdb8f..352e8712 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -846,23 +846,25 @@ async function _updateLinuxMirror( mirrorDomainWithScheme: string, ): Promise { const S = isInContainer() ? 'sudo ' : ''; + // APT otherwise returns zero when index downloads fail with transient errors. + const aptUpdate = `${S}apt-get update --error-on=any`; let filePath: string, currentDomainWithScheme: string | null; switch (osType) { case 'Debian': filePath = '/etc/apt/sources.list.d/debian.sources'; currentDomainWithScheme = await getCurrentMirrorDomain(filePath); if (currentDomainWithScheme) { - return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`; + return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${aptUpdate}`; } else { - return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`; + return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${aptUpdate}`; } case 'Ubuntu': filePath = '/etc/apt/sources.list.d/ubuntu.sources'; currentDomainWithScheme = await getCurrentMirrorDomain(filePath); if (currentDomainWithScheme) { - return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`; + return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${aptUpdate}`; } else { - return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`; + return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${aptUpdate}`; } case 'Alpine': filePath = '/etc/apk/repositories'; diff --git a/back/loaders/initData.ts b/back/loaders/initData.ts index 2620f58e..bfe3e61b 100644 --- a/back/loaders/initData.ts +++ b/back/loaders/initData.ts @@ -144,7 +144,11 @@ export default async () => { // 清空所有运行中的实例记录(服务重启后进程已不存在) await RunningInstanceModel.update( - { status: InstanceStatus.stopped }, + { + status: InstanceStatus.stopped, + finished_at: Math.floor(Date.now() / 1000), + // The previous process's exit code cannot be recovered after a restart. + }, { where: { status: InstanceStatus.running } }, ); diff --git a/back/services/sshKey.ts b/back/services/sshKey.ts index 521d5b08..f140df92 100644 --- a/back/services/sshKey.ts +++ b/back/services/sshKey.ts @@ -15,7 +15,15 @@ import { @Service() export default class SshKeyService { - private homedir = os.homedir(); + // OpenSSH uses the effective user's passwd entry, not the HOME override. + private get homedir(): string { + try { + return os.userInfo().homedir; + } catch { + // Custom container UIDs may have no passwd entry. Keep startup working. + return os.homedir(); + } + } private sshPath = config.sshdPath; private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config'); private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`; @@ -25,6 +33,10 @@ export default class SshKeyService { } private async initSshConfigFile() { + await fs.mkdir(path.dirname(this.sshConfigFilePath), { + recursive: true, + mode: 0o700, + }); let config = ''; const _exist = await fileExist(this.sshConfigFilePath); if (_exist) { diff --git a/back/services/system.ts b/back/services/system.ts index b5725d78..7567a3ee 100644 --- a/back/services/system.ts +++ b/back/services/system.ts @@ -239,7 +239,6 @@ export default class SystemService { return; } const command = await updateLinuxMirrorFile(info.linuxMirror || ''); - let hasError = false; this.scheduleService.runTask( command, { @@ -247,14 +246,15 @@ export default class SystemService { res?.setHeader('QL-Task-Pid', `${cp.pid}`); res?.end(); }, - onEnd: async () => { + onEnd: async (cp) => { this.sockService.sendMessage({ type: 'updateLinuxMirror', message: 'update linux mirror end', status: 'completed', }); onEnd?.(); - if (!hasError) { + // Tools such as sudo and apt may write warnings to stderr on success. + if (cp?.exitCode === 0 && cp.signalCode === null) { await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, @@ -262,7 +262,6 @@ export default class SystemService { } }, onError: async (message: string) => { - hasError = true; this.sockService.sendMessage({ type: 'updateLinuxMirror', message }); }, onLog: async (message: string) => { diff --git a/test/back/linux-mirror-result.test.cjs b/test/back/linux-mirror-result.test.cjs new file mode 100644 index 00000000..4945fb7d --- /dev/null +++ b/test/back/linux-mirror-result.test.cjs @@ -0,0 +1,176 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { spawn, spawnSync } = require('node:child_process'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const load = require('../helpers/load-security-module.cjs'); + +for (const scenario of [ + { + name: 'stderr warning with exit zero', + code: 'process.stderr.write("sudo: warning\\n")', + saved: true, + }, + { + name: 'nonzero exit without stderr', + code: 'process.exitCode = 7', + saved: false, + }, + { + name: 'signal termination', + code: 'process.kill(process.pid, "SIGTERM")', + saved: false, + }, + { name: 'spawn failure', shell: '/nonexistent-ql-test-shell', saved: false }, +]) { + test(`Linux mirror persistence follows the process result: ${scenario.name}`, async () => { + const logger = { info() {}, error() {} }; + const ScheduleService = load('back/services/schedule.ts', { + '../shared/cronScheduler': {}, + '../shared/pLimit': { runWithSystemLimit: async (_params, fn) => fn() }, + 'cross-spawn': { + spawn: () => + spawn( + process.execPath, + ['-e', scenario.code || ''], + scenario.shell ? { shell: scenario.shell } : {}, + ), + }, + }).default; + const schedule = new ScheduleService(logger); + let completion; + const runTask = schedule.runTask.bind(schedule); + schedule.runTask = (...args) => (completion = runTask(...args)); + const messages = [], + writes = []; + const SystemService = load('back/services/system.ts', { + os: { platform: () => 'linux' }, + '../config': {}, + '../config/const': {}, + '../config/util': { updateLinuxMirrorFile: async () => 'fixture' }, + '../data/dependence': {}, + '../data/notify': {}, + '../data/system': {}, + '../schedule/client': {}, + '../shared/pLimit': {}, + '../shared/i18n': {}, + '../shared/schedulerMutationLock': {}, + './notify': class {}, + './schedule': ScheduleService, + './sock': class {}, + }).default; + const service = new SystemService(logger, schedule, { + sendMessage: (message) => messages.push(message), + }); + service.getSystemConfig = async () => ({ id: 1, info: { lang: 'en-US' } }); + service.updateAuthDb = async (doc) => writes.push(doc); + let ended = 0; + await service.updateLinuxMirror( + { linuxMirror: 'https://mirror.example' }, + undefined, + () => ended++, + ); + const result = await completion; + assert.equal(ended, 1); + assert.equal(writes.length, scenario.saved ? 1 : 0); + assert.ok(messages.some((message) => message.status === 'completed')); + if (scenario.saved) { + assert.equal(result.code, 0); + assert.ok( + messages.some((message) => message.message.includes('sudo: warning')), + ); + assert.deepEqual(writes[0].info, { + lang: 'en-US', + linuxMirror: 'https://mirror.example', + }); + } + }); +} + +const hasApt = spawnSync('apt-get', ['--version']).status === 0; +for (const distribution of ['Debian', 'Ubuntu']) { + for (const existingMirror of [false, true]) { + test( + `${distribution} mirror update rejects failed index downloads with ${ + existingMirror ? 'an existing' : 'an empty' + } source`, + { skip: !hasApt }, + async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-apt-result-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const parts = path.join(root, 'parts'); + await fs.mkdir(parts); + await fs.mkdir(path.join(root, 'lists', 'partial'), { + recursive: true, + }); + const source = path.join(root, 'sources.list'); + await fs.writeFile( + source, + 'deb http://127.0.0.1:1/debian bookworm main\n', + ); + // Load only fixture configuration, including all writable APT paths. + const aptConfig = path.join(root, 'apt.conf'); + await fs.writeFile( + aptConfig, + `Dir::Etc::main "/dev/null"; +Dir::Etc::parts "${parts}"; +Dir::Etc::sourcelist "${source}"; +Dir::Etc::sourceparts "${parts}"; +Dir::State::lists "${path.join(root, 'lists')}"; +Dir::Cache "${path.join(root, 'cache')}"; +Dir::Log "${path.join(root, 'logs')}"; +Acquire::Retries "0"; +Acquire::http::Proxy "DIRECT"; +Acquire::http::Timeout "1"; +`, + ); + const util = load('back/config/util.ts', { + 'fs/promises': { + readFile: async (file) => + file === '/etc/os-release' + ? distribution + : existingMirror + ? 'URIs: http://old.example/debian\n' + : '', + }, + os: { platform: () => 'linux' }, + './index': {}, + './const': {}, + './share': {}, + './container': { isInContainer: () => false }, + '../loaders/logger': {}, + '../shared/utils': {}, + '../shared/fileAccess': {}, + '../data/dependence': {}, + }); + const previousOS = process.env.QL_OS_TYPE; + let command; + try { + process.env.QL_OS_TYPE = distribution.toLowerCase(); + command = await util.updateLinuxMirrorFile('http://127.0.0.1:1'); + } finally { + if (previousOS === undefined) delete process.env.QL_OS_TYPE; + else process.env.QL_OS_TYPE = previousOS; + } + // Exercise the generated updater without modifying host source files. + const [program, ...args] = command + .slice(command.lastIndexOf('&&') + 2) + .trim() + .split(/\s+/); + assert.equal(program, 'apt-get'); + const options = { + encoding: 'utf8', + timeout: 10000, + env: { ...process.env, APT_CONFIG: aptConfig }, + }; + const baseline = spawnSync('apt-get', ['update'], options); + assert.equal(baseline.status, 0, baseline.stderr); + assert.match(baseline.stderr, /Failed to fetch/); + const result = spawnSync(program, args, options); + assert.equal(result.status, 100, result.stderr); + assert.match(result.stderr, /Failed to fetch/); + }, + ); + } +} diff --git a/test/back/mirror-status.test.cjs b/test/back/mirror-status.test.cjs index bfb8209e..e0eec3fe 100644 --- a/test/back/mirror-status.test.cjs +++ b/test/back/mirror-status.test.cjs @@ -92,9 +92,8 @@ test('empty or missing refs never count as a synchronized mirror', (t) => { const result = spawnSync( 'bash', [script, source, mirror, ref], - options({ - QL_MIRROR_WAIT_SECONDS: '1', - }), + // Leave time to read both refs before checking the missing-ref diagnostic. + options(), ); assert.equal(result.status, 1, result.stderr + result.stdout); assert.match(result.stdout, /is missing refs\/heads\//); @@ -110,7 +109,7 @@ test('static releases require both the version branch and annotated tag object', const args = [script, source, mirror, 'v2.22.0', 'tag']; const env = { QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true', - QL_MIRROR_WAIT_SECONDS: '1', + QL_MIRROR_WAIT_SECONDS: '3', }; const partial = spawnSync('bash', args, options(env)); assert.equal(partial.status, 1, partial.stderr + partial.stdout); @@ -134,7 +133,7 @@ test('network failures and hung ref queries stop within the configured deadline' 'bash', [script, source, path.join(tmp, 'absent'), 'develop'], options({ - QL_MIRROR_WAIT_SECONDS: '1', + QL_MIRROR_WAIT_SECONDS: '3', }), ); assert.equal(failure.status, 1, failure.stderr + failure.stdout); diff --git a/test/back/restart-instance-recovery.test.cjs b/test/back/restart-instance-recovery.test.cjs new file mode 100644 index 00000000..a0e3c03d --- /dev/null +++ b/test/back/restart-instance-recovery.test.cjs @@ -0,0 +1,115 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { Sequelize, DataTypes } = require('sequelize'); +const load = require('../helpers/load-security-module.cjs'); + +test('startup closes interrupted instances without inventing exit codes or rewriting history', async (t) => { + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + t.after(() => db.close()); + const instances = db.define('Instance', { + status: DataTypes.INTEGER, + started_at: DataTypes.INTEGER, + finished_at: DataTypes.INTEGER, + exit_code: DataTypes.INTEGER, + }); + await db.sync(); + await instances.bulkCreate([ + { id: 1, status: 0, started_at: 100 }, + { id: 2, status: 1, started_at: 100, finished_at: 110, exit_code: 0 }, + { id: 3, status: 2, started_at: 100, finished_at: 120, exit_code: 143 }, + { id: 4, status: 3, started_at: 100, finished_at: 130, exit_code: 7 }, + ]); + const systemConfig = { info: { lang: 'en-US' } }; + const service = { + set_envs: async () => {}, + getAuthInfo: async () => ({}), + findApps: async () => [], + }; + const initData = load('back/loaders/initData.ts', { + typedi: { Container: { get: () => service } }, + '../services/dependence': class {}, + '../services/cron': class {}, + '../services/env': class {}, + '../services/system': class {}, + '../services/user': class {}, + '../services/open': class {}, + '../data/cron': { + CrontabModel: { update: async () => {}, findAll: async () => [] }, + CrontabStatus: { idle: 1 }, + }, + '../data/dependence': { + DependenceModel: { findAll: async () => [], update: async () => {} }, + DependenceStatus: { queued: 0 }, + }, + '../data/cronView': { + CrontabViewModel: { findAll: async () => [{}] }, + CronViewType: { 系统: 0 }, + }, + '../data/env': {}, + '../data/system': { + SystemModel: { findOrCreate: async () => [systemConfig] }, + AuthDataType: {}, + }, + '../data/open': { + AppModel: { + findOne: async () => ({ get: () => ({ scopes: ['dashboard'] }) }), + }, + }, + '../data/runningInstance': { + RunningInstanceModel: instances, + InstanceStatus: { running: 0, stopped: 2 }, + }, + '../config': {}, + '../config/util': { fileExist: async () => true, isDemoEnv: () => false }, + '../shared/store': { shareStore: { updateAuthInfo: async () => {} } }, + '../shared/i18n': { setLang() {} }, + './logger': {}, + '../schedule/client': { + readiness: { configure() {}, recover: async () => {} }, + }, + }).default; + // Dependency installation is unrelated to startup reconciliation. + const originalTimeout = global.setTimeout; + global.setTimeout = () => 0; + const before = Math.floor(Date.now() / 1000); + try { + await initData(); + } finally { + global.setTimeout = originalTimeout; + } + const rows = await instances.findAll({ order: [['id', 'ASC']], raw: true }); + assert.equal(rows[0].status, 2); + assert.ok( + rows[0].finished_at >= before && + rows[0].finished_at <= Math.floor(Date.now() / 1000), + ); + assert.equal(rows[0].exit_code, null); + assert.deepEqual( + rows + .slice(1) + .map(({ status, finished_at, exit_code }) => [ + status, + finished_at, + exit_code, + ]), + [ + [1, 110, 0], + [2, 120, 143], + [3, 130, 7], + ], + ); + global.setTimeout = () => 0; + try { + await initData(); + } finally { + global.setTimeout = originalTimeout; + } + assert.deepEqual( + await instances.findAll({ order: [['id', 'ASC']], raw: true }), + rows, + ); +}); diff --git a/test/back/ssh-config-home.test.cjs b/test/back/ssh-config-home.test.cjs new file mode 100644 index 00000000..bcc9d146 --- /dev/null +++ b/test/back/ssh-config-home.test.cjs @@ -0,0 +1,113 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const load = require('../helpers/load-security-module.cjs'); + +async function fixture(t, sameHome = false, missingUser = false) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-ssh-home-')); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const userHome = path.join(directory, 'user'); + const envHome = sameHome ? userHome : path.join(directory, 'env'); + const sshdPath = path.join(directory, 'ssh.d'); + await fs.mkdir(sshdPath); + const util = { + fileExist: async (file) => + fs.stat(file).then( + () => true, + () => false, + ), + rmPath: async (file) => fs.rm(file, { force: true }), + }; + const { writeFileWithLock } = load('back/shared/utils.ts', { + '../config/util': util, + }); + let initialized; + const ready = new Promise((resolve) => (initialized = resolve)); + const SshKeyService = load('back/services/sshKey.ts', { + os: { + ...os, + homedir: () => envHome, + userInfo: () => { + if (missingUser) throw Error('No passwd entry'); + return { homedir: userHome }; + }, + }, + '../config': { sshdPath }, + '../config/util': util, + '../shared/utils': { + writeFileWithLock: async (file, content, options) => { + await writeFileWithLock(file, content, options); + if (content.includes('Include ')) { + initialized(); + } + }, + }, + }).default; + const service = new SshKeyService({ error: assert.fail, warn: assert.fail }); + await ready; + return { service, userHome, envHome, sshdPath }; +} + +for (const sameHome of [false, true]) { + test(`SSH configuration uses the effective user home with ${ + sameHome ? 'matching' : 'overridden' + } HOME`, async (t) => { + const { service, userHome, envHome, sshdPath } = await fixture(t, sameHome); + const configFile = path.join(userHome, '.ssh/config'); + assert.equal( + await fs.readFile(configFile, 'utf8'), + `Include ${sshdPath}/*.config\n\n`, + ); + assert.equal((await fs.stat(path.dirname(configFile))).mode & 0o777, 0o700); + assert.equal((await fs.stat(configFile)).mode & 0o777, 0o600); + if (!sameHome) + await assert.rejects(fs.stat(path.join(envHome, '.ssh/config')), { + code: 'ENOENT', + }); + await fs.appendFile( + configFile, + 'Host existing\n Hostname example.com\n', + ); + await service.initSshConfigFile(); + const content = await fs.readFile(configFile, 'utf8'); + assert.equal(content.split('Include ').length, 2); + assert.match(content, /Host existing/); + }); +} + +test('SSH initialization still works for a custom UID without a passwd entry', async (t) => { + const { envHome, sshdPath } = await fixture(t, true, true); + assert.equal( + await fs.readFile(path.join(envHome, '.ssh/config'), 'utf8'), + `Include ${sshdPath}/*.config\n\n`, + ); +}); + +test('SSH alias changes retain private key permissions and remove old credentials', async (t) => { + const { service, sshdPath } = await fixture(t); + const { + getSubscriptionSshAlias, + } = require('../../back/shared/subscriptionPath'); + const original = 'private/中文@v1'; + const renamed = 'renamed/中文@v2'; + await service.addSSHKey('fixture-private-key', original, '127.0.0.1'); + const first = getSubscriptionSshAlias(original); + assert.equal((await fs.stat(path.join(sshdPath, first))).mode & 0o777, 0o400); + assert.match( + await fs.readFile(path.join(sshdPath, `${first}.config`), 'utf8'), + /Hostname 127\.0\.0\.1/, + ); + await service.removeSSHKey(original, '127.0.0.1'); + await service.addSSHKey('fixture-private-key', renamed, '127.0.0.1'); + await assert.rejects(fs.stat(path.join(sshdPath, first)), { code: 'ENOENT' }); + await assert.rejects(fs.stat(path.join(sshdPath, `${first}.config`)), { + code: 'ENOENT', + }); + assert.equal( + (await fs.stat(path.join(sshdPath, getSubscriptionSshAlias(renamed)))) + .mode & 0o777, + 0o400, + ); +}); diff --git a/test/back/static-mirror-trigger.test.cjs b/test/back/static-mirror-trigger.test.cjs index c50bbfc6..fb4a8936 100644 --- a/test/back/static-mirror-trigger.test.cjs +++ b/test/back/static-mirror-trigger.test.cjs @@ -312,7 +312,12 @@ test('static verification still fails when fresh notifications never synchronize }, ); assert.equal(result.status, 1, result.stderr + result.stdout); - assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '2'); + // The retry contract is checked above. A short polling deadline can expire + // while the REST fixture is still sending its second notification. + const notifications = Number( + git('-C', source, 'rev-list', '--count', triggerRef), + ); + assert.ok(notifications >= 1 && notifications <= 2); assert.match(result.stdout, /::error::Mirror did not match/); const invalid = spawnSync( 'bash',