diff --git a/automatic_updates.module b/automatic_updates.module
index 9f389b04f7128d9d2910f98abbde67ce577c6c6d..c95de6fb40d6d71ec8584f29b44f09c3c638c1d6 100644
--- a/automatic_updates.module
+++ b/automatic_updates.module
@@ -55,7 +55,7 @@ function automatic_updates_page_top(array &$page_top) {
     }
     $results = $checker->getResults('warning');
     if ($results) {
-      \Drupal::messenger()->addWarning(t('Your site does not pass some readiness checks for automatic updates. It might not be completely eligible for <a href="@readiness_checks">automatic updates</a>.', ['@readiness_checks' => 'https://www.drupal.org/docs/8/update/automatic-updates#readiness-checks']));
+      \Drupal::messenger()->addWarning(t('Your site does not pass some readiness checks for automatic updates. Depending on the nature of the failures, it might effect the eligibility for <a href="@readiness_checks">automatic updates</a>.', ['@readiness_checks' => 'https://www.drupal.org/docs/8/update/automatic-updates#readiness-checks']));
       foreach ($results as $message) {
         \Drupal::messenger()->addWarning($message);
       }
diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml
index ba47c91629d52da91cd1a49685505ec4721ad621..d0168241b455acd54a764d3ac948f428d69430ae 100644
--- a/automatic_updates.services.yml
+++ b/automatic_updates.services.yml
@@ -48,11 +48,20 @@ services:
       - '@automatic_updates.drupal_finder'
     tags:
       - { name: readiness_checker, category: error}
-  automatic_updates.modified_code:
-    class: Drupal\automatic_updates\ReadinessChecker\ModifiedCode
+  automatic_updates.modified_files:
+    class: Drupal\automatic_updates\Services\ModifiedFiles
     arguments:
       - '@logger.channel.automatic_updates'
       - '@automatic_updates.drupal_finder'
+      - '@http_client'
+      - '@config.factory'
+  automatic_updates.modified_files_checker:
+    class: Drupal\automatic_updates\ReadinessChecker\ModifiedFiles
+    arguments:
+      - '@automatic_updates.modified_files'
+      - '@extension.list.module'
+      - '@extension.list.profile'
+      - '@extension.list.theme'
     tags:
       - { name: readiness_checker, category: warning}
   automatic_updates.file_ownership:
diff --git a/config/install/automatic_updates.settings.yml b/config/install/automatic_updates.settings.yml
index 18afbc79f6de87e5cb576b2906e8b4393b81fbdd..799251acd21d103ec82f2f6a74987a342dfec665 100644
--- a/config/install/automatic_updates.settings.yml
+++ b/config/install/automatic_updates.settings.yml
@@ -1,8 +1,9 @@
 # Public service announcement URI endpoint.
 # TODO:  Update to correct end point once it is available. See
 # https://www.drupal.org/project/automatic_updates/issues/3045273
-psa_endpoint: 'http://localhost/automatic_updates/test-json'
+psa_endpoint: 'http://example.com/automatic_updates/test-json'
 enable_psa: true
 notify: true
 check_frequency: 43200
 enable_readiness_checks: true
+download_uri: 'https://ftp.drupal.org/files/projects'
diff --git a/config/schema/automatic_updates.schema.yml b/config/schema/automatic_updates.schema.yml
index db243d342027609388026a3a520398e74bbf496e..35a15326ad4a91c74bfa6b1b33e9eb082f119dba 100644
--- a/config/schema/automatic_updates.schema.yml
+++ b/config/schema/automatic_updates.schema.yml
@@ -17,3 +17,6 @@ automatic_updates.settings:
     enable_readiness_checks:
       type: boolean
       label: 'Enable readiness checks'
+    download_uri:
+      type: string
+      label: 'Endpoint URI for file hashes and quasi patch files'
diff --git a/src/ReadinessChecker/ModifiedCode.php b/src/ReadinessChecker/ModifiedCode.php
deleted file mode 100644
index f8c0b05b949cdf4131cd05feded04c77de2ad4a9..0000000000000000000000000000000000000000
--- a/src/ReadinessChecker/ModifiedCode.php
+++ /dev/null
@@ -1,106 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\ReadinessChecker;
-
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use DrupalFinder\DrupalFinder;
-use Psr\Log\LoggerInterface;
-
-/**
- * Modified code checker.
- */
-class ModifiedCode implements ReadinessCheckerInterface {
-  use StringTranslationTrait;
-
-  /**
-   * The logger.
-   *
-   * @var \Psr\Log\LoggerInterface
-   */
-  protected $logger;
-
-  /**
-   * The Drupal root path.
-   *
-   * @var string
-   */
-  protected $drupalRoot;
-
-  /**
-   * The vendor path.
-   *
-   * @var string
-   */
-  protected $vendorPath;
-
-  /**
-   * The drupal finder service.
-   *
-   * @var \DrupalFinder\DrupalFinder
-   */
-  protected $drupalFinder;
-
-  /**
-   * ReadOnlyFilesystem constructor.
-   *
-   * @param \Psr\Log\LoggerInterface $logger
-   *   The logger.
-   * @param \DrupalFinder\DrupalFinder $drupal_finder
-   *   The Drupal finder.
-   */
-  public function __construct(LoggerInterface $logger, DrupalFinder $drupal_finder) {
-    $this->logger = $logger;
-    $this->drupalFinder = $drupal_finder;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function run() {
-    $messages = [];
-    if (!$this->getDrupalRoot()) {
-      $messages[] = $this->t('The Drupal root directory could not be located.');
-      return $messages;
-    }
-    $this->modifiedCode($messages);
-    return $messages;
-  }
-
-  /**
-   * Get the Drupal root path.
-   *
-   * @return string
-   *   The Drupal root path.
-   */
-  protected function getDrupalRoot() {
-    if (!$this->drupalRoot && $this->drupalFinder->locateRoot(getcwd())) {
-      $this->drupalRoot = $this->drupalFinder->getDrupalRoot();
-    }
-    return $this->drupalRoot;
-  }
-
-  /**
-   * Get the vendor path.
-   *
-   * @return string
-   *   The vendor path.
-   */
-  protected function getVendorPath() {
-    if (!$this->vendorPath && $this->drupalFinder->locateRoot(getcwd())) {
-      $this->vendorPath = $this->drupalFinder->getVendorDir();
-    }
-    return $this->vendorPath;
-  }
-
-  /**
-   * Check if the site contains any modified code.
-   *
-   * @param array $messages
-   *   The messages array of translatable strings.
-   */
-  protected function modifiedCode(array &$messages) {
-    // TODO: Implement file hashing logic against all code files.
-    // See: https://www.drupal.org/project/automatic_updates/issues/3050804
-  }
-
-}
diff --git a/src/ReadinessChecker/ModifiedFiles.php b/src/ReadinessChecker/ModifiedFiles.php
new file mode 100644
index 0000000000000000000000000000000000000000..3991254369870459c2c40bb8ca674c7232d7d0d8
--- /dev/null
+++ b/src/ReadinessChecker/ModifiedFiles.php
@@ -0,0 +1,142 @@
+<?php
+
+namespace Drupal\automatic_updates\ReadinessChecker;
+
+use Drupal\automatic_updates\Services\ModifiedFilesInterface;
+use Drupal\Core\Extension\ExtensionList;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Modified code checker.
+ */
+class ModifiedFiles implements ReadinessCheckerInterface {
+  use StringTranslationTrait;
+
+  /**
+   * The modified files service.
+   *
+   * @var \Drupal\automatic_updates\Services\ModifiedFilesInterface
+   */
+  protected $modifiedFiles;
+
+  /**
+   * The module extension list.
+   *
+   * @var \Drupal\Core\Extension\ExtensionList
+   */
+  protected $modules;
+
+  /**
+   * The profile extension list.
+   *
+   * @var \Drupal\Core\Extension\ExtensionList
+   */
+  protected $profiles;
+
+  /**
+   * The theme extension list.
+   *
+   * @var \Drupal\Core\Extension\ExtensionList
+   */
+  protected $themes;
+
+  /**
+   * An array of array of strings of extension paths.
+   *
+   * @var string[]string[]
+   */
+  protected $paths;
+
+  /**
+   * ModifiedFiles constructor.
+   *
+   * @param \Drupal\automatic_updates\Services\ModifiedFilesInterface $modified_files
+   *   The modified files service.
+   *   The config factory.
+   * @param \Drupal\Core\Extension\ExtensionList $modules
+   *   The module extension list.
+   * @param \Drupal\Core\Extension\ExtensionList $profiles
+   *   The profile extension list.
+   * @param \Drupal\Core\Extension\ExtensionList $themes
+   *   The theme extension list.
+   */
+  public function __construct(ModifiedFilesInterface $modified_files, ExtensionList $modules, ExtensionList $profiles, ExtensionList $themes) {
+    $this->modifiedFiles = $modified_files;
+    $this->modules = $modules;
+    $this->profiles = $profiles;
+    $this->themes = $themes;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function run() {
+    return $this->modifiedFilesCheck();
+  }
+
+  /**
+   * Check if the site contains any modified code.
+   *
+   * @return array
+   *   An array of translatable strings if any checks fail.
+   */
+  protected function modifiedFilesCheck() {
+    $messages = [];
+    $extensions = [];
+    $extensions['drupal'] = $this->modules->get('system')->info;
+    foreach ($this->getExtensionsTypes() as $extension_type) {
+      foreach ($this->getInfos($extension_type) as $extension_name => $info) {
+        if (substr($this->getPath($extension_type, $extension_name), 0, 4) !== 'core') {
+          $extensions[$extension_name] = $info;
+        }
+      }
+    }
+    foreach ($this->modifiedFiles->getModifiedFiles($extensions) as $file) {
+      $messages[] = $this->t('The hash for @file does not match its original. Updates that include that file will fail and require manual intervention.', ['@file' => $file]);
+    }
+    return $messages;
+  }
+
+  /**
+   * Get the extension types.
+   *
+   * @return array
+   *   The extension types.
+   */
+  protected function getExtensionsTypes() {
+    return ['modules', 'profiles', 'themes'];
+  }
+
+  /**
+   * Returns an array of info files information of available extensions.
+   *
+   * @param string $extension_type
+   *   The extension type.
+   *
+   * @return array
+   *   An associative array of extension information arrays, keyed by extension
+   *   name.
+   */
+  protected function getInfos($extension_type) {
+    return $this->{$extension_type}->getAllAvailableInfo();
+  }
+
+  /**
+   * Returns an extension file path.
+   *
+   * @param string $extension_type
+   *   The extension type.
+   * @param string $extension_name
+   *   The extension name.
+   *
+   * @return string
+   *   An extension file path or NULL if it does not exist.
+   */
+  protected function getPath($extension_type, $extension_name) {
+    if (!isset($this->paths[$extension_type])) {
+      $this->paths[$extension_type] = $this->{$extension_type}->getPathnames();
+    }
+    return isset($this->paths[$extension_type][$extension_name]) ? $this->paths[$extension_type][$extension_name] : NULL;
+  }
+
+}
diff --git a/src/ReadinessChecker/ReadOnlyFilesystem.php b/src/ReadinessChecker/ReadOnlyFilesystem.php
index e172e92bb45fad90feae0b7e23b63b82d899266e..2fb8c9ef074ff089c4f29aef78753dc094b80ab7 100644
--- a/src/ReadinessChecker/ReadOnlyFilesystem.php
+++ b/src/ReadinessChecker/ReadOnlyFilesystem.php
@@ -54,6 +54,9 @@ class ReadOnlyFilesystem extends Filesystem {
 
   /**
    * Check if the filesystem is read only.
+   *
+   * @return array
+   *   An array of translatable strings if any checks fail.
    */
   protected function readOnlyCheck() {
     $messages = [];
diff --git a/src/Services/ModifiedFiles.php b/src/Services/ModifiedFiles.php
new file mode 100644
index 0000000000000000000000000000000000000000..c0e250a5786b198c539718ee3cf478aaf913c494
--- /dev/null
+++ b/src/Services/ModifiedFiles.php
@@ -0,0 +1,225 @@
+<?php
+
+namespace Drupal\automatic_updates\Services;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Url;
+use DrupalFinder\DrupalFinder;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Promise\EachPromise;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Modified files service.
+ */
+class ModifiedFiles implements ModifiedFilesInterface {
+
+  /**
+   * The logger.
+   *
+   * @var \Psr\Log\LoggerInterface
+   */
+  protected $logger;
+
+  /**
+   * The HTTP client.
+   *
+   * @var \GuzzleHttp\ClientInterface
+   */
+  protected $httpClient;
+
+  /**
+   * The config factory.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $configFactory;
+
+  /**
+   * The drupal finder service.
+   *
+   * @var \DrupalFinder\DrupalFinder
+   */
+  protected $drupalFinder;
+
+  /**
+   * ModifiedCode constructor.
+   *
+   * @param \Psr\Log\LoggerInterface $logger
+   *   The logger.
+   * @param \DrupalFinder\DrupalFinder $drupal_finder
+   *   The Drupal finder.
+   * @param \GuzzleHttp\ClientInterface $http_client
+   *   The HTTP client.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory.
+   */
+  public function __construct(LoggerInterface $logger, DrupalFinder $drupal_finder, ClientInterface $http_client, ConfigFactoryInterface $config_factory) {
+    $this->logger = $logger;
+    $this->drupalFinder = $drupal_finder;
+    $this->httpClient = $http_client;
+    $this->configFactory = $config_factory;
+    $this->drupalFinder->locateRoot(getcwd());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getModifiedFiles(array $extensions = []) {
+    $modified_files = [];
+    $promises = $this->getHashRequests($extensions);
+    // Wait until all the requests are finished.
+    (new EachPromise($promises, [
+      'concurrency' => 4,
+      'fulfilled' => function ($resource) use (&$modified_files) {
+        $this->processHashes($resource, $modified_files);
+      },
+    ]))->promise()->wait();
+    return $modified_files;
+  }
+
+  /**
+   * Process checking hashes of files from external URL.
+   *
+   * @param resource $resource
+   *   A resource handle.
+   * @param array $modified_files
+   *   The list of modified files.
+   */
+  protected function processHashes($resource, array &$modified_files) {
+    while (($line = fgets($resource)) !== FALSE) {
+      list($hash, $file) = preg_split('/\s+/', $line, 2);
+      $file = trim($file);
+      // If the line is empty, proceed to the next line.
+      if (empty($hash) && empty($file)) {
+        continue;
+      }
+      // If one of the values is invalid, log and continue.
+      if (!$hash || !$file) {
+        $this->logger->error('@hash or @file is empty; the hash file is malformed for this line.', ['@hash' => $hash, '@file' => $file]);
+        continue;
+      }
+      $file_path = $this->drupalFinder->getDrupalRoot() . DIRECTORY_SEPARATOR . $file;
+      if (!file_exists($file_path) || hash_file('sha512', $file_path) !== $hash) {
+        $modified_files[] = $file_path;
+      }
+    }
+    if (!feof($resource)) {
+      $this->logger->error('Stream for resource closed prematurely.');
+    }
+    fclose($resource);
+  }
+
+  /**
+   * Get an iterator of promises that return a resource stream.
+   *
+   * @param array $extensions
+   *   The list of extensions, keyed by extension name and value the info array.
+   *
+   * @codingStandardsIgnoreStart
+   *
+   * @return \Generator
+   *
+   * @@codingStandardsIgnoreEnd
+   */
+  protected function getHashRequests(array $extensions) {
+    foreach ($extensions as $extension_name => $info) {
+      $url = $this->buildUrl($extension_name, $info);
+      yield $this->getPromise($url);
+    }
+  }
+
+  /**
+   * Get a promise.
+   *
+   * @param string $url
+   *   The URL.
+   *
+   * @return \GuzzleHttp\Promise\PromiseInterface
+   *   The promise.
+   */
+  protected function getPromise($url) {
+    return $this->httpClient->requestAsync('GET', $url, [
+      'stream' => TRUE,
+      'read_timeout' => 30,
+    ])
+      ->then(function (ResponseInterface $response) {
+        return $response->getBody()->detach();
+      });
+  }
+
+  /**
+   * Build an extension's hash file URL.
+   *
+   * @param string $extension_name
+   *   The extension name.
+   * @param array $info
+   *   The extension's info.
+   *
+   * @return string
+   *   The URL endpoint with for an extension.
+   */
+  protected function buildUrl($extension_name, array $info) {
+    $version = $this->getExtensionVersion($extension_name, $info);
+    $project_name = $this->getProjectName($extension_name, $info);
+    $hash_name = $this->getHashName($info);
+    $uri = ltrim($this->configFactory->get('automatic_updates.settings')->get('download_uri'), '/');
+    return Url::fromUri($uri . "/$project_name/$version/$hash_name")->toString();
+  }
+
+  /**
+   * Get the extension version.
+   *
+   * @param string $extension_name
+   *   The extension name.
+   * @param array $info
+   *   The extension's info.
+   *
+   * @return string|null
+   *   The version or NULL if undefined.
+   */
+  protected function getExtensionVersion($extension_name, array $info) {
+    $version = isset($info['version']) ? $info['version'] : NULL;
+    // TODO: consider using ocramius/package-versions to discover the installed
+    // version from composer.lock.
+    // See https://www.drupal.org/project/automatic_updates/issues/3054002
+    return $version;
+  }
+
+  /**
+   * Get the extension's project name.
+   *
+   * @param string $extension_name
+   *   The extension name.
+   * @param array $info
+   *   The extension's info.
+   *
+   * @return string
+   *   The project name or fallback to extension name if project is undefined.
+   */
+  protected function getProjectName($extension_name, array $info) {
+    $project_name = isset($info['project']) ? $info['project'] : $extension_name;
+    // TODO: parse the composer.json for the name if it isn't set in info.
+    // See https://www.drupal.org/project/automatic_updates/issues/3054002.
+    return $project_name;
+  }
+
+  /**
+   * Get the hash file name.
+   *
+   * @param array $info
+   *   The extension's info.
+   *
+   * @return string|null
+   *   The hash name.
+   */
+  protected function getHashName(array $info) {
+    $hash_name = 'SHA512SUMS';
+    if (isset($info['project'])) {
+      $hash_name .= '-package';
+    }
+    return $hash_name;
+  }
+
+}
diff --git a/src/Services/ModifiedFilesInterface.php b/src/Services/ModifiedFilesInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..982bd3ed425afc4bac2064c5b371a6ef09efa56e
--- /dev/null
+++ b/src/Services/ModifiedFilesInterface.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Drupal\automatic_updates\Services;
+
+/**
+ * Modified files service interface.
+ */
+interface ModifiedFilesInterface {
+
+  /**
+   * Get list of modified files.
+   *
+   * @param array $extensions
+   *   The list of extensions, keyed by extension name with values an info
+   *   array.
+   *
+   * @return array
+   *   The modified files.
+   */
+  public function getModifiedFiles(array $extensions = []);
+
+}
diff --git a/tests/modules/test_automatic_updates/src/Controller/HashesController.php b/tests/modules/test_automatic_updates/src/Controller/HashesController.php
new file mode 100644
index 0000000000000000000000000000000000000000..f2026c9eb4b15e19d009b9a37a4f29fd8ad80d87
--- /dev/null
+++ b/tests/modules/test_automatic_updates/src/Controller/HashesController.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\test_automatic_updates\Controller;
+
+use Drupal\Core\Controller\ControllerBase;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Class HashesController.
+ */
+class HashesController extends ControllerBase {
+
+  /**
+   * Test hashes controller.
+   *
+   * @param string $extension
+   *   The extension name.
+   * @param string $version
+   *   The version string.
+   *
+   * @return \Symfony\Component\HttpFoundation\Response
+   *   A file with hashes.
+   */
+  public function hashes($extension, $version) {
+    $response = Response::create();
+    $response->headers->set('Content-Type', 'text/plain');
+    if ($extension === 'core' && $version === '8.7.0') {
+      $response->setContent("2cedbfcde76961b1f65536e3c69e13d8ad850619235f4aa2752ae66fe5e5a2d928578279338f099b5318d92c410040e995cb62ba1cc4512ec17cf21715c760a2  core/LICENSE.txt\n");
+    }
+    elseif ($extension === 'core' && $version === '8.0.0') {
+      // Fake out a change in the LICENSE.txt.
+      $response->setContent("2d4ce6b272311ca4159056fb75138eba1814b65323c35ae5e0978233918e45e62bb32fdd2e0e8f657954fd5823c045762b3b59645daf83246d88d8797726e02c  core/LICENSE.txt\n");
+    }
+    return $response;
+  }
+
+}
diff --git a/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml b/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml
index 1920578446758ecc6567b1a121808fa97f9f9e35..7c9ce3413268e95199dd72aef1517776cb2dc40b 100644
--- a/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml
+++ b/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml
@@ -12,3 +12,10 @@ test_automatic_updates.json_test_denied_controller:
     _title: 'JSON'
   requirements:
     _access: 'FALSE'
+test_automatic_updates.hashes_endpoint:
+  path: '/automatic_updates/{extension}/{version}/SHA512SUMS'
+  defaults:
+    _controller: '\Drupal\test_automatic_updates\Controller\HashesController::hashes'
+    _title: 'SHA512SUMS'
+  requirements:
+    _access: 'TRUE'
diff --git a/tests/src/Functional/AutomaticUpdatesTest.php b/tests/src/Functional/AutomaticUpdatesTest.php
index 1ce69e985b4103473810c3af10893efff17cc6c2..7f5c337b63ff99cadfd0f0948bf51c95e335fb39 100644
--- a/tests/src/Functional/AutomaticUpdatesTest.php
+++ b/tests/src/Functional/AutomaticUpdatesTest.php
@@ -92,9 +92,11 @@ class AutomaticUpdatesTest extends BrowserTestBase {
    */
   public function testReadinessChecks() {
     // Test manually running readiness checks.
+    $url = $this->buildUrl('<front>') . '/automatic_updates';
+    $this->config('automatic_updates.settings')->set('download_uri', $url);
     $this->drupalGet(Url::fromRoute('automatic_updates.settings'));
     $this->clickLink('run the readiness checks');
-    $this->assertSession()->pageTextContains('Your site does not pass some readiness checks for automatic updates. It might not be completely eligible for automatic updates.');
+    $this->assertSession()->pageTextContains('Your site does not pass some readiness checks for automatic updates. Depending on the nature of the failures, it might effect the eligibility for automatic updates.');
   }
 
 }
diff --git a/tests/src/Functional/ModifiedFilesTest.php b/tests/src/Functional/ModifiedFilesTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..e7df3e7acdb97ba61a15f0d5924ad25370d8f4e7
--- /dev/null
+++ b/tests/src/Functional/ModifiedFilesTest.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Functional;
+
+use Drupal\automatic_updates\Services\ModifiedFiles;
+use Drupal\Core\Url;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests of automatic updates.
+ *
+ * @group automatic_updates
+ */
+class ModifiedFilesTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'automatic_updates',
+    'test_automatic_updates',
+  ];
+
+  /**
+   * Tests modified files service.
+   */
+  public function testModifiedFiles() {
+    // No modified code.
+    $modified_files = new TestModifiedFiles(
+      $this->container->get('logger.channel.automatic_updates'),
+      $this->container->get('automatic_updates.drupal_finder'),
+      $this->container->get('http_client'),
+      $this->container->get('config.factory')
+    );
+    $this->initHashesEndpoint($modified_files, 'core', '8.7.0');
+    $files = $modified_files->getModifiedFiles(['system' => []]);
+    $this->assertEmpty($files);
+
+    // Hash doesn't match i.e. modified code, including contrib logic.
+    $this->initHashesEndpoint($modified_files, 'core', '8.0.0');
+    $files = $modified_files->getModifiedFiles(['system' => []]);
+    $this->assertCount(1, $files);
+    $this->assertStringEndsWith('core/LICENSE.txt', $files[0]);
+  }
+
+  /**
+   * Set the hashes endpoint.
+   *
+   * @param TestModifiedFiles $modified_code
+   *   The modified code object.
+   * @param string $extension
+   *   The extension name.
+   * @param string $version
+   *   The version.
+   */
+  protected function initHashesEndpoint(TestModifiedFiles $modified_code, $extension, $version) {
+    $modified_code->endpoint = $this->buildUrl(Url::fromRoute('test_automatic_updates.hashes_endpoint', [
+      'extension' => $extension,
+      'version' => $version,
+    ]));
+  }
+
+}
+
+/**
+ * Class TestModifiedCode.
+ */
+class TestModifiedFiles extends ModifiedFiles {
+
+  /**
+   * The endpoint url.
+   *
+   * @var string
+   */
+  public $endpoint;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function buildUrl($extension_name, array $info) {
+    return $this->endpoint;
+  }
+
+}
diff --git a/tests/src/Kernel/ReadinessChecker/ModifiedCodeTest.php b/tests/src/Kernel/ReadinessChecker/ModifiedCodeTest.php
deleted file mode 100644
index 0606aa64ad38ddf9614b0e0fe5e9c20e6114691a..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/ReadinessChecker/ModifiedCodeTest.php
+++ /dev/null
@@ -1,32 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel\ReadinessChecker;
-
-use Drupal\automatic_updates\ReadinessChecker\ModifiedCode;
-use Drupal\KernelTests\KernelTestBase;
-
-/**
- * Tests modified code readiness checking.
- *
- * @group automatic_updates
- */
-class ModifiedCodeTest extends KernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public static $modules = [
-    'automatic_updates',
-  ];
-
-  /**
-   * Tests the functionality of modified code readiness checks.
-   */
-  public function testModifiedCode() {
-    // No modified code.
-    $modified_code = new ModifiedCode($this->container->get('logger.channel.automatic_updates'), $this->container->get('automatic_updates.drupal_finder'));
-    $messages = $modified_code->run();
-    $this->assertEmpty($messages);
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessChecker/ModifiedFilesTest.php b/tests/src/Kernel/ReadinessChecker/ModifiedFilesTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0aa2340d8dc5eac119f0fed530d9f76d4bfe51f0
--- /dev/null
+++ b/tests/src/Kernel/ReadinessChecker/ModifiedFilesTest.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Kernel;
+
+use Drupal\automatic_updates\ReadinessChecker\ModifiedFiles;
+use Drupal\automatic_updates\Services\ModifiedFilesInterface;
+use Drupal\KernelTests\KernelTestBase;
+use Prophecy\Argument;
+
+/**
+ * Tests of automatic updates.
+ *
+ * @group automatic_updates
+ */
+class ModifiedFilesTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'automatic_updates',
+    'test_automatic_updates',
+  ];
+
+  /**
+   * Tests modified files service.
+   */
+  public function testModifiedFiles() {
+    /** @var \Prophecy\Prophecy\ObjectProphecy|\Drupal\automatic_updates\Services\ModifiedFilesInterface $service */
+    $service = $this->prophesize(ModifiedFilesInterface::class);
+    $service->getModifiedFiles(Argument::type('array'))->willReturn([]);
+    $modules = $this->container->get('extension.list.module');
+    $profiles = $this->container->get('extension.list.profile');
+    $themes = $this->container->get('extension.list.theme');
+
+    // No modified code.
+    $modified_files = new ModifiedFiles(
+      $service->reveal(),
+      $modules,
+      $profiles,
+      $themes
+    );
+    $messages = $modified_files->run();
+    $this->assertEmpty($messages);
+
+    // Hash doesn't match i.e. modified code.
+    $service->getModifiedFiles(Argument::type('array'))->willReturn(['core/LICENSE.txt']);
+    $messages = $modified_files->run();
+    $this->assertCount(1, $messages);
+  }
+
+}