diff --git a/package.json b/package.json index ecafcda..266b220 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@types/node-localstorage": "^1.3.3", "@types/semver": "^7.7.1", "@types/shelljs": "^0.8.17", + "@types/sinon": "^21.0.1", "chai": "^4", "eslint": "^9", "eslint-config-oclif": "^6", @@ -64,6 +65,7 @@ "mocha": "^10", "oclif": "^4", "shx": "^0.3.3", + "sinon": "^22.0.0", "ts-node": "^10.9.2", "typescript": "^5" }, diff --git a/src/checks/check-network.ts b/src/checks/check-network.ts index daa499c..2cb9e02 100644 --- a/src/checks/check-network.ts +++ b/src/checks/check-network.ts @@ -44,23 +44,18 @@ export const checkNetwork = { }, async checkSeedList() { - if(configStore.hasProjectFlag('seedListChecked')) { - return; - } - + // Seed list membership is dynamic — the upstream lists (especially testnet / + // integrationnet) get rotated and nodes can be dropped. Re-validate on every run + // instead of trusting a cached "passed" flag, otherwise a node that was removed + // upstream keeps launching and only gets rejected later by the protocol. const { type } = configStore.getNetworkInfo(); - - clm.preStep(`Checking inclusion into seed list for ${type.toUpperCase()} network...`); const { nodeId, projectDir } = configStore.getProjectInfo(); const seedListFile = path.resolve(projectDir, 'seedlist'); - if (fs.existsSync(seedListFile)) { - const found = fs.readFileSync(seedListFile, 'utf8').includes(nodeId); - if (found) { - clm.postStep(`✅ Node ID found in ${type.toUpperCase()} seed list.`); - configStore.setProjectFlag('seedListChecked', true); - return; - } - } + + clm.preStep(`Checking inclusion into seed list for ${type.toUpperCase()} network...`); + + const isInLocalSeedList = () => + fs.existsSync(seedListFile) && fs.readFileSync(seedListFile, 'utf8').includes(nodeId); const printNotFoundError = () => { clm.warn(`Node ID not found in ${type.toUpperCase()} seed list. You may try again later.`); @@ -69,29 +64,45 @@ export const checkNetwork = { } if (type === 'mainnet') { - // the mainnet seed list comed from a network release - printNotFoundError(); - } else { - - const url = `https://constellationlabs-dag.s3.us-west-1.amazonaws.com/${type}-seedlist` - const seedList = await fetch(url) - .then(res => { - if (res.ok) return res.text(); - throw new Error(`Failed`); - }) - .catch(() => { - clm.error(`Failed to fetch seed list from ${url}. Try again later.`); - return ''; - }); - if (seedList.includes(nodeId)) { - clm.postStep(`Node ID found in ${type.toUpperCase()} seed list.`); - fs.writeFileSync(seedListFile, seedList); - configStore.setProjectFlag('seedListChecked', true); + // the mainnet seed list comes from a network release (downloaded by install.sh); + // validate against the local file the node actually mounts. + if (isInLocalSeedList()) { + clm.postStep(`✅ Node ID found in ${type.toUpperCase()} seed list.`); + return; } - else { - printNotFoundError(); + + printNotFoundError(); + return; + } + + const url = `https://constellationlabs-dag.s3.us-west-1.amazonaws.com/${type}-seedlist` + const remoteSeedList = await fetch(url) + .then(res => { + if (res.ok) return res.text(); + throw new Error(`Failed`); + }) + .catch(() => ''); + + if (remoteSeedList) { + if (remoteSeedList.includes(nodeId)) { + // refresh the seed list the node mounts so it stays current + fs.writeFileSync(seedListFile, remoteSeedList); + clm.postStep(`✅ Node ID found in ${type.toUpperCase()} seed list.`); + return; } + + printNotFoundError(); + return; } + + // Remote unreachable — fall back to the local seed list the node uses. + clm.warn(`Could not fetch the ${type.toUpperCase()} seed list from ${url}. Falling back to local copy.`); + if (isInLocalSeedList()) { + clm.postStep(`✅ Node ID found in local ${type.toUpperCase()} seed list.`); + return; + } + + printNotFoundError(); }, async configureIpAddress() { diff --git a/src/helpers/key-file-helper.ts b/src/helpers/key-file-helper.ts index ff30790..9cc2ea8 100644 --- a/src/helpers/key-file-helper.ts +++ b/src/helpers/key-file-helper.ts @@ -200,7 +200,6 @@ export const keyFileHelper = { } configStore.setProjectFlag('duplicateNodeIdChecked', false); - configStore.setProjectFlag('seedListChecked', false); }, async promptIfNoKeyFile() { diff --git a/src/helpers/prompt-helper.ts b/src/helpers/prompt-helper.ts index d711bb0..38263b5 100644 --- a/src/helpers/prompt-helper.ts +++ b/src/helpers/prompt-helper.ts @@ -71,24 +71,19 @@ export const promptHelper = { throw new Error('No supported networks found'); } - if (supportedTypes.length === 1) { - configStore.setNetworkInfo({type: supportedTypes[0], version: "latest"}); - // configStore.setEnvNetworkInfo(configStore.getNetworkEnvInfo(supportedTypes[0])); - return; - } - - const networkType = await select({ - choices: [ - {disabled: !supportedTypes.includes('mainnet'), name: 'Mainnet', value: 'mainnet'}, - {disabled: !supportedTypes.includes('testnet'), name: 'Testnet', value: 'testnet'}, - {disabled: !supportedTypes.includes('integrationnet'), name: 'Integrationnet', value: 'integrationnet'} - ], - message: 'Select network type:' - }) as NetworkType; + const networkType = supportedTypes.length === 1 + ? supportedTypes[0] + : await select({ + choices: [ + {disabled: !supportedTypes.includes('mainnet'), name: 'Mainnet', value: 'mainnet'}, + {disabled: !supportedTypes.includes('testnet'), name: 'Testnet', value: 'testnet'}, + {disabled: !supportedTypes.includes('integrationnet'), name: 'Integrationnet', value: 'integrationnet'} + ], + message: 'Select network type:' + }) as NetworkType; configStore.setNetworkInfo({type: networkType, version: "latest"}); configStore.setProjectFlag('duplicateNodeIdChecked', false); - configStore.setProjectFlag('seedListChecked', false); configStore.setProjectFlag('javaMemoryChecked', false); }, diff --git a/test/checks/check-network.test.ts b/test/checks/check-network.test.ts new file mode 100644 index 0000000..90a3f5c --- /dev/null +++ b/test/checks/check-network.test.ts @@ -0,0 +1,127 @@ +import {expect} from 'chai' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import sinon from 'sinon' + +import {checkNetwork} from '../../src/checks/check-network.js' +import {clm} from '../../src/clm.js' +import {configStore} from '../../src/config-store.js' +import {NetworkType} from '../../src/config-store.js' + +// real node id from the integrationnet seed-list bug report +const NODE_ID = 'de51af87c1ac6bd06e428009b7f34dd3b76c5799ff44b8924b8630fc6ed28eb7c148e1c538c29a136a5bc946d528cf51fc48b6256eecdb3bfdf71ed3f6c0f8d7' +const OTHER_ID = 'aaaa1111bbbb2222cccc3333dddd4444eeee5555ffff6666aaaa7777bbbb8888' + +// sentinel so we can assert the "not found" path without calling process.exit +class SeedListError extends Error {} + +describe('checkNetwork.checkSeedList', () => { + let projectDir: string + let seedListFile: string + let fetchStub: sinon.SinonStub + let errorStub: sinon.SinonStub + let postStepStub: sinon.SinonStub + let warnStub: sinon.SinonStub + + function stubConfig(type: NetworkType) { + sinon.stub(configStore, 'getNetworkInfo').returns({supportedTypes: [type], type, version: 'latest'}) + sinon.stub(configStore, 'getProjectInfo').returns({nodeId: NODE_ID, projectDir} as ReturnType) + } + + function mockFetch(body: string) { + fetchStub.resolves({ok: true, text: async () => body} as Response) + } + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cpilot-seedlist-')) + seedListFile = path.join(projectDir, 'seedlist') + + fetchStub = sinon.stub(globalThis, 'fetch') + sinon.stub(clm, 'preStep') + postStepStub = sinon.stub(clm, 'postStep') + warnStub = sinon.stub(clm, 'warn') + errorStub = sinon.stub(clm, 'error').throws(new SeedListError()) + }) + + afterEach(() => { + sinon.restore() + fs.rmSync(projectDir, {force: true, recursive: true}) + }) + + it('passes and refreshes the local seedlist when the remote list contains the node id', async () => { + stubConfig('integrationnet') + const remote = `${OTHER_ID}\n${NODE_ID}\n` + mockFetch(remote) + + await checkNetwork.checkSeedList() + + expect(errorStub.called, 'should not error').to.equal(false) + expect(postStepStub.calledWithMatch(/found/i)).to.equal(true) + expect(fs.readFileSync(seedListFile, 'utf8')).to.equal(remote) + }) + + it('errors when the node id is not in the remote list (the reported bug)', async () => { + stubConfig('integrationnet') + mockFetch(`${OTHER_ID}\n`) + + try { + await checkNetwork.checkSeedList() + expect.fail('expected checkSeedList to error out') + } catch (error) { + expect(error).to.be.instanceOf(SeedListError) + } + + expect(errorStub.called, 'should report not-found').to.equal(true) + expect(fs.existsSync(seedListFile), 'must not write seedlist on miss').to.equal(false) + }) + + it('re-validates every run instead of trusting a cached flag', async () => { + // even if a prior "checked" flag were set, the remote must still be consulted + sinon.stub(configStore, 'hasProjectFlag').returns(true) + stubConfig('integrationnet') + mockFetch(`${NODE_ID}\n`) + + await checkNetwork.checkSeedList() + + expect(fetchStub.calledOnce, 'remote list must be fetched').to.equal(true) + }) + + it('falls back to the local seedlist when the remote is unreachable', async () => { + stubConfig('integrationnet') + fs.writeFileSync(seedListFile, `${NODE_ID}\n`) + fetchStub.rejects(new Error('network down')) + + await checkNetwork.checkSeedList() + + expect(errorStub.called, 'should not error on fallback hit').to.equal(false) + expect(warnStub.calledWithMatch(/fall(ing)? back/i)).to.equal(true) + expect(postStepStub.calledWithMatch(/found/i)).to.equal(true) + }) + + it('errors when the remote is unreachable and the local list misses', async () => { + stubConfig('integrationnet') + fs.writeFileSync(seedListFile, `${OTHER_ID}\n`) + fetchStub.rejects(new Error('network down')) + + try { + await checkNetwork.checkSeedList() + expect.fail('expected checkSeedList to error out') + } catch (error) { + expect(error).to.be.instanceOf(SeedListError) + } + + expect(errorStub.called).to.equal(true) + }) + + it('validates mainnet against the local release seedlist without fetching', async () => { + stubConfig('mainnet') + fs.writeFileSync(seedListFile, `${NODE_ID}\n`) + + await checkNetwork.checkSeedList() + + expect(fetchStub.called, 'mainnet must not hit S3').to.equal(false) + expect(errorStub.called).to.equal(false) + expect(postStepStub.calledWithMatch(/found/i)).to.equal(true) + }) +}) diff --git a/yarn.lock b/yarn.lock index 14bbc0d..632d0b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1454,6 +1454,28 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-5.6.0.tgz#41dd6093d34652cddb5d5bdeee04eafc33826668" integrity sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g== +"@sinonjs/commons@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^15.4.0": + version "15.4.0" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz#5d40c151a9e66075fe4520bec40bccfe54931962" + integrity sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA== + dependencies: + "@sinonjs/commons" "^3.0.1" + +"@sinonjs/samsam@^10.0.2": + version "10.0.2" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-10.0.2.tgz#d2cb34f0bcddb955b6971585c2f0334e68a9e66d" + integrity sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A== + dependencies: + "@sinonjs/commons" "^3.0.1" + type-detect "^4.1.0" + "@smithy/abort-controller@^4.0.5": version "4.0.5" resolved "https://registry.yarnpkg.com/@smithy/abort-controller/-/abort-controller-4.0.5.tgz#2872a12d0f11dfdcc4254b39566d5f24ab26a4ab" @@ -2141,6 +2163,18 @@ "@types/node" "*" glob "^11.0.3" +"@types/sinon@^21.0.1": + version "21.0.1" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-21.0.1.tgz#f995e2afdf15be832d5f1645803d82a8eb95a1bc" + integrity sha512-5yoJSqLbjH8T9V2bksgRayuhpZy+723/z6wBOR+Soe4ZlXC0eW8Na71TeaZPUWDQvM7LYKa9UGFc6LRqxiR5fQ== + dependencies: + "@types/sinonjs__fake-timers" "*" + +"@types/sinonjs__fake-timers@*": + version "15.0.1" + resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz#49f731d9453f52d64dd79f5a5626c1cf1b81bea4" + integrity sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w== + "@types/uuid@^9.0.1": version "9.0.8" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba" @@ -3436,6 +3470,11 @@ diff@^5.1.0, diff@^5.2.0: resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== +diff@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-9.0.0.tgz#297c31cd7c280f13dfe335791ec2063bd4a73a6f" + integrity sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw== + doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -7370,6 +7409,16 @@ simple-get@^2.7.0: once "^1.3.1" simple-concat "^1.0.0" +sinon@^22.0.0: + version "22.0.0" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-22.0.0.tgz#01cea95c919468f6ef01e21d406397e5c02aad9b" + integrity sha512-sq/6DpdXOrLyfbKlXLg/Usc7xu8YXPeLkOFZRvA3bNUSA2lhbrZ06yuXbH1fkzBPCbz9O10+7hznzUsjaYNm0Q== + dependencies: + "@sinonjs/commons" "^3.0.1" + "@sinonjs/fake-timers" "^15.4.0" + "@sinonjs/samsam" "^10.0.2" + diff "^9.0.0" + smart-buffer@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" @@ -7865,6 +7914,11 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" +type-detect@4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + type-detect@^4.0.0, type-detect@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c"