From 4e9f345ce13617a1055cd67c7b8251b77230f862 Mon Sep 17 00:00:00 2001
From: Willie Ruemmele <willieruemmele@gmail.com>
Date: Thu, 23 May 2024 12:29:00 -0600
Subject: [PATCH] feat: add network check and UT (#746)

* feat: add network check and UT

* chore: remove registry checks, moved to plugin-trust

* docs: update ping check message
---
 package.json             |  1 +
 src/commands/doctor.ts   |  1 -
 src/diagnostics.ts       | 51 ++++++++++++++++++++++++++++------
 src/doctor.ts            |  6 ++--
 test/diagnostics.test.ts | 59 +++++++++++++++++++++++++++++++++++++++-
 yarn.lock                |  2 +-
 6 files changed, 105 insertions(+), 15 deletions(-)

diff --git a/package.json b/package.json
index c143bd09..a20dc1ca 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
   "bugs": "https://github.com/forcedotcom/cli/issues",
   "dependencies": {
     "@inquirer/input": "^2.1.6",
+    "@jsforce/jsforce-node": "^3.2.0",
     "@oclif/core": "^3.26.5",
     "@salesforce/core": "^7.3.5",
     "@salesforce/kit": "^3.1.0",
diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts
index 4f5a45ab..7ec5217d 100644
--- a/src/commands/doctor.ts
+++ b/src/commands/doctor.ts
@@ -58,7 +58,6 @@ export default class Doctor extends SfCommand<SfDoctorDiagnosis> {
 
   public async run(): Promise<SfDoctorDiagnosis> {
     const { flags } = await this.parse(Doctor);
-    // this.doctor = SFDoctor.getInstance();
     this.doctor = SFDoctor.init(this.config);
     const lifecycle = Lifecycle.getInstance();
 
diff --git a/src/diagnostics.ts b/src/diagnostics.ts
index cf1a295b..68f5807f 100644
--- a/src/diagnostics.ts
+++ b/src/diagnostics.ts
@@ -6,22 +6,16 @@
  */
 
 import childProcess from 'node:child_process';
-
+import { got } from 'got';
 import { Interfaces } from '@oclif/core';
 import { Lifecycle, Messages } from '@salesforce/core';
+import { Connection } from '@jsforce/jsforce-node';
 import { SfDoctor, SfDoctorDiagnosis } from './doctor.js';
 
-// const SUPPORTED_SHELLS = [
-//   'bash',
-//   'zsh',
-//   'powershell'
-//   'cmd.exe'
-// ];
-
 export type DiagnosticStatus = {
   testName: string;
   status: 'pass' | 'fail' | 'warn' | 'unknown';
-}
+};
 
 Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
 const messages = Messages.loadMessages('@salesforce/plugin-info', 'diagnostics');
@@ -128,6 +122,45 @@ export class Diagnostics {
     await Lifecycle.getInstance().emit('Doctor:diagnostic', { testName, status });
   }
 
+  public async networkCheck(): Promise<void> {
+    await Promise.all(
+      [
+        // salesforce endpoints
+        'https://test.salesforce.com',
+        'https://appexchange.salesforce.com/services/data',
+      ].map(async (url) => {
+        try {
+          const conn = new Connection();
+          await conn.request(url);
+          await Lifecycle.getInstance().emit('Doctor:diagnostic', { testName: `can access: ${url}`, status: 'pass' });
+        } catch (e) {
+          await Lifecycle.getInstance().emit('Doctor:diagnostic', { testName: `can't access: ${url}`, status: 'fail' });
+          this.doctor.addSuggestion(
+            `Cannot reach ${url} - potential network configuration error, check proxies, firewalls, environment variables`
+          );
+        }
+      })
+    );
+    // our S3 bucket, use the buildmanifest to avoid downloading the entire CLI
+    const manifestUrl =
+      'https://developer.salesforce.com/media/salesforce-cli/sf/channels/stable/sf-win32-x64-buildmanifest';
+    try {
+      await got.get(manifestUrl);
+      await Lifecycle.getInstance().emit('Doctor:diagnostic', {
+        testName: `can access: ${manifestUrl}`,
+        status: 'pass',
+      });
+    } catch (e) {
+      await Lifecycle.getInstance().emit('Doctor:diagnostic', {
+        testName: `can't access: ${manifestUrl}`,
+        status: 'fail',
+      });
+      this.doctor.addSuggestion(
+        `Cannot reach ${manifestUrl} - potential network configuration error, check proxies, firewalls, environment variables`
+      );
+    }
+  }
+
   /**
    * Checks and warns if any plugins are linked.
    */
diff --git a/src/doctor.ts b/src/doctor.ts
index 6d895944..522074db 100644
--- a/src/doctor.ts
+++ b/src/doctor.ts
@@ -31,7 +31,7 @@ export type SfDoctor = {
   writeFileSync(filePath: string, contents: string): string;
   writeStderr(contents: string): Promise<boolean>;
   writeStdout(contents: string): Promise<boolean>;
-}
+};
 
 type CliConfig = Partial<Interfaces.Config> & { nodeEngine: string };
 
@@ -46,7 +46,7 @@ export type SfDoctorDiagnosis = {
   commandName?: string;
   commandExitCode?: string | number;
   logFilePaths: string[];
-}
+};
 
 Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
 const messages = Messages.loadMessages('@salesforce/plugin-info', 'doctor');
@@ -67,7 +67,7 @@ export class Doctor implements SfDoctor {
   public readonly id: number;
 
   // Contains all gathered data and results of diagnostics.
-  private diagnosis: SfDoctorDiagnosis;
+  private readonly diagnosis: SfDoctorDiagnosis;
   private stdoutWriteStream: fs.WriteStream | undefined;
   private stderrWriteStream: fs.WriteStream | undefined;
 
diff --git a/test/diagnostics.test.ts b/test/diagnostics.test.ts
index 9e9a5024..57560209 100644
--- a/test/diagnostics.test.ts
+++ b/test/diagnostics.test.ts
@@ -12,6 +12,7 @@ import { fromStub, spyMethod, stubInterface, stubMethod } from '@salesforce/ts-s
 import { Config, Interfaces } from '@oclif/core';
 import { Lifecycle } from '@salesforce/core';
 import { ux } from '@oclif/core';
+import { Connection } from '@jsforce/jsforce-node';
 import { Doctor } from '../src/doctor.js';
 import { Diagnostics } from '../src/diagnostics.js';
 
@@ -87,7 +88,7 @@ describe('Diagnostics', () => {
     const results = diagnostics.run();
 
     // This will have to be updated with each new test
-    expect(results.length).to.equal(4);
+    expect(results.length).to.equal(5);
     expect(childProcessExecStub.called).to.be.true;
     expect(lifecycleEmitSpy.called).to.be.true;
     expect(lifecycleEmitSpy.args[0][0]).to.equal('Doctor:diagnostic');
@@ -95,6 +96,62 @@ describe('Diagnostics', () => {
     expect(lifecycleEmitSpy.args[0][1]).to.have.property('status');
   });
 
+  describe('networkCheck', () => {
+    it('passes when all URLs can be reached', async () => {
+      stubMethod(sandbox, Connection.prototype, 'request').resolves('{}');
+
+      const dr = Doctor.init(oclifConfig);
+      const diagnostics = new Diagnostics(dr, oclifConfig);
+      await diagnostics.networkCheck();
+
+      expect(drAddSuggestionSpy.called).to.be.false;
+      expect(lifecycleEmitSpy.called).to.be.true;
+      expect(lifecycleEmitSpy.args[0][1]).to.deep.equal({
+        status: 'pass',
+        testName: 'can access: https://test.salesforce.com',
+      });
+    });
+
+    it('fails when one URL cannot be reached', async () => {
+      stubMethod(sandbox, Connection.prototype, 'request').rejects(
+        '{"error":"unsupported_grant_type","error_description":"grant type not supported"}'
+      );
+
+      const dr = Doctor.init(oclifConfig);
+      const diagnostics = new Diagnostics(dr, oclifConfig);
+      await diagnostics.networkCheck();
+
+      expect(drAddSuggestionSpy.called).to.be.true;
+      expect(lifecycleEmitSpy.called).to.be.true;
+      expect(lifecycleEmitSpy.args[0][1]).to.deep.equal({
+        status: 'fail',
+        testName: "can't access: https://test.salesforce.com",
+      });
+    });
+
+    it('fails when one URL cannot be reached and others can be', async () => {
+      stubMethod(sandbox, Connection.prototype, 'request')
+        .onFirstCall()
+        .resolves('{}')
+        .rejects('{"error":"unsupported_grant_type","error_description":"grant type not supported"}');
+
+      const dr = Doctor.init(oclifConfig);
+      const diagnostics = new Diagnostics(dr, oclifConfig);
+      await diagnostics.networkCheck();
+
+      expect(drAddSuggestionSpy.called).to.be.true;
+      expect(lifecycleEmitSpy.called).to.be.true;
+      expect(lifecycleEmitSpy.args[0][1]).to.deep.equal({
+        status: 'pass',
+        testName: 'can access: https://test.salesforce.com',
+      });
+      expect(lifecycleEmitSpy.args[1][1]).to.deep.equal({
+        status: 'fail',
+        testName: "can't access: https://appexchange.salesforce.com/services/data",
+      });
+    });
+  });
+
   describe('outdatedCliVersionCheck', () => {
     it('passes when CLI version is equal to latest', async () => {
       childProcessExecStub.callsFake((cmdString, opts, cb: (e: unknown, stdout: unknown, stderr: unknown) => void) => {
diff --git a/yarn.lock b/yarn.lock
index c80c6ae9..787c45a8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1388,7 +1388,7 @@
     strip-ansi "6.0.1"
     ts-retry-promise "^0.8.0"
 
-"@salesforce/core@^7.3.1", "@salesforce/core@^7.3.3", "@salesforce/core@^7.3.5", "@salesforce/core@^7.3.6", "@salesforce/core@^7.3.8":
+"@salesforce/core@^7.3.3", "@salesforce/core@^7.3.5", "@salesforce/core@^7.3.6", "@salesforce/core@^7.3.8":
   version "7.3.8"
   resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-7.3.8.tgz#8a646b5321f08c0fb4d22e2fa8b1d60b3a20df9b"
   integrity sha512-VWhXHfjwjtC3pJWYp8wt5/fnNQ5tK61ovMG5eteXzVD2oFd7og1f6YjwuAzoYIZK7kYWWv7KJfGtCsPs7Zw+Ww==