chromium/chrome/browser/ui/webui/ash/manage_mirrorsync/manage_mirrorsync_dialog_browsertest.cc

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "chrome/browser/ui/webui/ash/manage_mirrorsync/manage_mirrorsync_dialog.h"

#include <vector>

#include "ash/constants/ash_features.h"
#include "base/files/file_util.h"
#include "base/memory/raw_ptr.h"
#include "base/run_loop.h"
#include "base/strings/strcat.h"
#include "base/test/bind.h"
#include "base/test/gmock_callback_support.h"
#include "base/test/mock_callback.h"
#include "base/test/scoped_feature_list.h"
#include "base/threading/thread_restrictions.h"
#include "base/values.h"
#include "chrome/browser/ash/drive/drive_integration_service.h"
#include "chrome/browser/ash/drive/drivefs_test_support.h"
#include "chrome/browser/ash/file_manager/path_util.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/webui/ash/manage_mirrorsync/manage_mirrorsync.mojom.h"
#include "chrome/browser/ui/webui/ash/system_web_dialog_delegate.h"
#include "chrome/common/webui_url_constants.h"
#include "chrome/test/base/in_process_browser_test.h"
#include "components/drive/drive_pref_names.h"
#include "components/prefs/pref_service.h"
#include "content/public/browser/web_ui.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "storage/browser/file_system/external_mount_points.h"
#include "testing/gmock/include/gmock/gmock.h"

namespace ash {

using base::test::RunOnceCallback;
using testing::_;
using testing::AllOf;
using testing::ElementsAre;
using testing::ElementsAreArray;
using testing::Eq;
using testing::IsEmpty;
using testing::SizeIs;

// The Mojo bindings for JS output a `base::FilePath` as an object with a 'path'
// key. When parsing it in C++ as a `base::Value` this requires a bit of
// wrangling. To avoid doing this over and over, this MATCHER unwraps the
// `base::Value` and matches against a supplied `std::vector<std::string>`.
MATCHER_P(MojoFilePaths, matcher, "") {
  std::vector<std::string> paths;
  for (const base::Value& dict_value : arg) {
    const std::string* path_value = dict_value.GetDict().FindString("path");
    paths.push_back(*path_value);
  }
  return testing::ExplainMatchResult(matcher, paths, result_listener);
}

// Matcher to unwrap the `base::Value::Dict` from `getSyncingPaths` and extract
// the "error" key. The value of this is cast into a `GetSyncPathError` to
// compare.
MATCHER_P(SyncPathError, matcher, "") {
  std::optional<int> error = arg.FindInt("error");
  EXPECT_TRUE(error.has_value());
  auto get_sync_path_error =
      static_cast<manage_mirrorsync::mojom::PageHandler::GetSyncPathError>(
          error.value());
  return testing::ExplainMatchResult(matcher, get_sync_path_error,
                                     result_listener);
}

// Matcher to unwrap the `base::Value::Dict` from `getSyncPaths` and extract the
// "syncingPaths" key. This can be coupled wit the `MojoFilePaths` matcher to
// perform element comparison on the resultant `base::Value::Dict` in the array.
MATCHER_P(SyncingPaths, matcher, "") {
  const base::Value::List* paths = arg.FindList("syncingPaths");
  EXPECT_NE(paths, nullptr);
  return testing::ExplainMatchResult(matcher, *paths, result_listener);
}

// Helper to observe the DriveIntegrationService for when mirroring is enabled.
class DriveMirrorSyncStatusObserver
    : public drive::DriveIntegrationService::Observer {
 public:
  explicit DriveMirrorSyncStatusObserver(bool expected_status)
      : expected_status_(expected_status) {
    quit_closure_ = run_loop_.QuitClosure();
  }

  void WaitForStatusChange() { run_loop_.Run(); }

  void OnMirroringEnabled() override {
    quit_closure_.Run();
    EXPECT_TRUE(expected_status_);
  }

  void OnMirroringDisabled() override {
    quit_closure_.Run();
    EXPECT_FALSE(expected_status_);
  }

 private:
  base::RunLoop run_loop_;
  base::RepeatingClosure quit_closure_;
  bool expected_status_ = false;
};

class ManageMirrorSyncDialogTest : public InProcessBrowserTest {
 public:
  ManageMirrorSyncDialogTest() {
    feature_list_.InitAndEnableFeature(features::kDriveFsMirroring);

    EXPECT_TRUE(temp_dir_.CreateUniqueTempDir());
  }

  ManageMirrorSyncDialogTest(const ManageMirrorSyncDialogTest&) = delete;
  ManageMirrorSyncDialogTest& operator=(const ManageMirrorSyncDialogTest&) =
      delete;

  void SetUpInProcessBrowserTestFixture() override {
    create_drive_integration_service_ = base::BindRepeating(
        &ManageMirrorSyncDialogTest::CreateDriveIntegrationService,
        base::Unretained(this));
    service_factory_for_test_ = std::make_unique<
        drive::DriveIntegrationServiceFactory::ScopedFactoryForTest>(
        &create_drive_integration_service_);
  }

  drive::DriveIntegrationService* CreateDriveIntegrationService(
      Profile* profile) {
    base::ScopedAllowBlockingForTesting allow_blocking;
    base::FilePath mount_point = profile->GetPath().Append("drivefs");
    fake_drivefs_helpers_[profile] =
        std::make_unique<drive::FakeDriveFsHelper>(profile, mount_point);
    auto* integration_service = new drive::DriveIntegrationService(
        profile, "", mount_point,
        fake_drivefs_helpers_[profile]->CreateFakeDriveFsListenerFactory());
    return integration_service;
  }

  // Show the MirrorSync dialog and wait for it to complete loading.
  void ShowDialog() {
    content::WebContentsAddedObserver observer;
    ManageMirrorSyncDialog::Show(browser()->profile());
    dialog_contents_ = observer.GetWebContents();
    EXPECT_TRUE(content::WaitForLoadStop(dialog_contents_));
    EXPECT_EQ(dialog_contents_->GetLastCommittedURL().host(),
              chrome::kChromeUIManageMirrorSyncHost);
  }

  void SetUpMyFilesAndDialog(std::vector<std::string> paths) {
    // Revoke the existing mount points and setup `temp_dir_` as the mount point
    // for ~/MyFiles.
    my_files_dir_ = temp_dir_.GetPath().Append("MyFiles");
    storage::ExternalMountPoints::GetSystemInstance()->RevokeAllFileSystems();
    storage::ExternalMountPoints::GetSystemInstance()->RegisterFileSystem(
        file_manager::util::GetDownloadsMountPointName(browser()->profile()),
        storage::kFileSystemTypeLocal, storage::FileSystemMountOption(),
        my_files_dir_);

    // Create the supplied paths as individual folders. This ensures a path such
    // as foo/bar gets foo created and bar created as a child folder.
    for (const auto& path : paths) {
      base::FilePath file_path(path);
      const auto& parts = file_path.GetComponents();
      base::FilePath base_path = my_files_dir_;
      for (const auto& path_part : parts) {
        base_path = base_path.Append(path_part);
        base::ScopedAllowBlockingForTesting allow_blocking;
        ASSERT_TRUE(base::CreateDirectory(base_path));
      }
    }

    ShowDialog();
  }

  drivefs::FakeDriveFs& SetUpMirrorSyncAndDialog(bool enabled) {
    // Revoke the existing mount points and setup `temp_dir_` as the mount point
    // for ~/MyFiles.
    my_files_dir_ = temp_dir_.GetPath().Append("MyFiles");
    storage::ExternalMountPoints::GetSystemInstance()->RevokeAllFileSystems();
    storage::ExternalMountPoints::GetSystemInstance()->RegisterFileSystem(
        file_manager::util::GetDownloadsMountPointName(browser()->profile()),
        storage::kFileSystemTypeLocal, storage::FileSystemMountOption(),
        my_files_dir_);

    // Turning on MirrorSync requires MyFiles to exist first.
    {
      base::ScopedAllowBlockingForTesting allow_blocking;
      base::CreateDirectory(my_files_dir_);
    }

    drivefs::FakeDriveFs& fake_drivefs =
        fake_drivefs_helpers_[browser()->profile()]->fake_drivefs();

    // Toggle the MirrorSync preference to enable / disable the feature.
    {
      DriveMirrorSyncStatusObserver observer(enabled);
      drive::DriveIntegrationService* const service =
          drive::DriveIntegrationServiceFactory::FindForProfile(
              browser()->profile());
      observer.Observe(service);
      // Turning on the sync will add ~/MyFiles as the sync path, which will
      // call GetSyncingPaths internally.
      if (enabled) {
        EXPECT_CALL(fake_drivefs, GetSyncingPaths(_))
            .WillOnce(RunOnceCallback<0>(drive::FileError::FILE_ERROR_OK,
                                         std::vector<base::FilePath>()));
      }
      browser()->profile()->GetPrefs()->SetBoolean(
          drive::prefs::kDriveFsEnableMirrorSync, enabled);
      observer.WaitForStatusChange();
    }

    ShowDialog();

    return fake_drivefs;
  }

  // Returns a pair of std::vector where the first element contains a list of
  // base::FilePaths that represent absolute paths parented at `my_files_dir_`
  // and the second element contains the expected paths returned from
  // `getSyncingPaths` i.e. with the leading `my_files_dir_` replaced with a "/"
  // character. For example: CreateExpectedPaths({{"foo"}}) will have the
  // following first element:
  //   /MyFiles/foo
  // And the second element will be:
  //   /foo
  std::pair<std::vector<base::FilePath>, std::vector<std::string>>
  CreateExpectedPaths(std::vector<std::string> paths) {
    std::vector<base::FilePath> absolute_paths;
    std::vector<std::string> expected_paths;
    for (const auto& path : paths) {
      absolute_paths.push_back(my_files_dir_.Append(path));
      expected_paths.push_back(base::StrCat({"/", path}));
    }
    return std::make_pair(absolute_paths, expected_paths);
  }

  // Helper to invoke the `getChildFolders` method on chrome://manage-mirrorsync
  // dialog and extract it's response.
  base::Value::List GetChildFolders(const std::string& path) {
    const std::string js_expression = base::StrCat(
        {"((async () => { "
         "const {BrowserProxy} = await import('./browser_proxy.js');"
         "const handler = BrowserProxy.getInstance().handler;"
         "const {paths} = await handler.getChildFolders({path: '",
         path,
         "'});"
         "return paths; })())"});
    auto response = content::EvalJs(dialog_contents_.get(), js_expression);

    base::Value response_list = response.ExtractList();
    return response_list.GetList().Clone();
  }

  // Helper to invoke the `getSyncingPaths` method on chrome://manage-mirrorsync
  // dialog and extract it's response.
  base::Value::Dict GetSyncingPaths() {
    const std::string js_expression =
        "((async () => { "
        "const {BrowserProxy} = await import('./browser_proxy.js');"
        "const handler = BrowserProxy.getInstance().handler;"
        "const response = await handler.getSyncingPaths();"
        "return response; })())";
    auto response = content::EvalJs(dialog_contents_.get(), js_expression);
    EXPECT_TRUE(response.value.is_dict());
    return response.value.GetDict().Clone();
  }

  void TearDown() override {
    InProcessBrowserTest::TearDown();
    storage::ExternalMountPoints::GetSystemInstance()->RevokeAllFileSystems();
  }

  base::test::ScopedFeatureList feature_list_;
  base::ScopedTempDir temp_dir_;
  base::FilePath my_files_dir_;
  raw_ptr<content::WebContents, DanglingUntriaged> dialog_contents_;

  drive::DriveIntegrationServiceFactory::FactoryCallback
      create_drive_integration_service_;
  std::unique_ptr<drive::DriveIntegrationServiceFactory::ScopedFactoryForTest>
      service_factory_for_test_;
  std::map<Profile*, std::unique_ptr<drive::FakeDriveFsHelper>>
      fake_drivefs_helpers_;
};

IN_PROC_BROWSER_TEST_F(ManageMirrorSyncDialogTest, ExpectedScenarios) {
  SetUpMyFilesAndDialog({{"foo/bar"}, {"baz"}});
  {
    // Only children folders should be returned (i.e. not all descendants).
    const auto& child_folders = GetChildFolders("/");
    EXPECT_THAT(child_folders, SizeIs(2));
    EXPECT_THAT(child_folders, MojoFilePaths(ElementsAre("/baz", "/foo")));
  }

  {
    // Paths should be returned as absolute.
    const auto& child_folders = GetChildFolders("/foo");
    EXPECT_THAT(child_folders, SizeIs(1));
    EXPECT_THAT(child_folders, MojoFilePaths(ElementsAre("/foo/bar")));
  }

  {
    // Folders with no children should return an empty list.
    const auto& child_folders = GetChildFolders("/baz");
    EXPECT_THAT(child_folders, IsEmpty());
  }
}

IN_PROC_BROWSER_TEST_F(ManageMirrorSyncDialogTest, InvalidScenarios) {
  SetUpMyFilesAndDialog({{"foo/bar"}, {"baz"}});

  {
    // Not supplying a path should return an empty list.
    const auto& child_folders = GetChildFolders("");
    EXPECT_THAT(child_folders, IsEmpty());
  }

  {
    // Path traversals should return an empty list.
    const auto& child_folders = GetChildFolders("/../../");
    EXPECT_THAT(child_folders, IsEmpty());
  }

  {
    // Relative paths should are invalid, empty list is returned.
    const auto& child_folders = GetChildFolders("foo/bar");
    EXPECT_THAT(child_folders, IsEmpty());
  }

  {
    // Non-existent directories should return an empty list.
    const auto& child_folders = GetChildFolders("/non-existant");
    EXPECT_THAT(child_folders, IsEmpty());
  }
}

IN_PROC_BROWSER_TEST_F(ManageMirrorSyncDialogTest, CertainItemsAreIgnored) {
  SetUpMyFilesAndDialog({{"foo/bar"}, {"baz"}});

  {
    base::ScopedAllowBlockingForTesting allow_blocking;
    // Create a file under foo, files should be ignored.
    ASSERT_TRUE(base::WriteFile(my_files_dir_.Append("foo/test.txt"), "blah"));

    // Any items (files or folders) with a leading dot should be ignored.
    ASSERT_TRUE(base::CreateDirectory(my_files_dir_.Append("baz/.Trash")));

    // Items nested in dot folders should also be ignored.
    ASSERT_TRUE(base::CreateDirectory(my_files_dir_.Append("baz/.Trash/dir")));
  }

  {
    // File should be ignored as well as any dot folders.
    const auto& child_folders = GetChildFolders("/");
    EXPECT_THAT(child_folders, SizeIs(2));
    EXPECT_THAT(child_folders, MojoFilePaths(ElementsAre("/baz", "/foo")));
  }

  {
    // Supplied dot folders should return an empty list.
    const auto& child_folders = GetChildFolders("/baz/.Trash");
    EXPECT_THAT(child_folders, IsEmpty());
  }
}

using manage_mirrorsync::mojom::PageHandler;

IN_PROC_BROWSER_TEST_F(ManageMirrorSyncDialogTest,
                       GetSyncingPathsMirrorSyncEnabled) {
  drivefs::FakeDriveFs& fake_drivefs =
      SetUpMirrorSyncAndDialog(/*enabled=*/true);

  {
    // No paths currently syncing returns empty.
    const auto& [mock_paths, expected_paths] = CreateExpectedPaths({});
    EXPECT_CALL(fake_drivefs, GetSyncingPaths(_))
        .WillOnce(
            RunOnceCallback<0>(drive::FileError::FILE_ERROR_OK, mock_paths));
    const auto& syncing_paths = GetSyncingPaths();
    EXPECT_THAT(syncing_paths,
                AllOf(SyncPathError(PageHandler::GetSyncPathError::kSuccess),
                      SyncingPaths(IsEmpty())));
  }

  {
    // Single path being synced returns correctly.
    const auto& [mock_paths, expected_paths] = CreateExpectedPaths({{"foo"}});
    EXPECT_CALL(fake_drivefs, GetSyncingPaths(_))
        .WillOnce(
            RunOnceCallback<0>(drive::FileError::FILE_ERROR_OK, mock_paths));
    const auto& syncing_paths = GetSyncingPaths();
    EXPECT_THAT(
        syncing_paths,
        AllOf(SyncPathError(PageHandler::GetSyncPathError::kSuccess),
              SyncingPaths(MojoFilePaths(ElementsAreArray(expected_paths)))));
  }

  {
    // Nested syncing path returns itself but not the parents.
    const auto& [mock_paths, expected_paths] =
        CreateExpectedPaths({{"foo/bar"}});
    EXPECT_CALL(fake_drivefs, GetSyncingPaths(_))
        .WillOnce(
            RunOnceCallback<0>(drive::FileError::FILE_ERROR_OK, mock_paths));
    const auto& syncing_paths = GetSyncingPaths();
    EXPECT_THAT(
        syncing_paths,
        AllOf(SyncPathError(PageHandler::GetSyncPathError::kSuccess),
              SyncingPaths(MojoFilePaths(ElementsAreArray(expected_paths)))));
  }

  {
    // Failure from retrieval of paths should return error + empty vector.
    const auto& [mock_paths, expected_paths] = CreateExpectedPaths({});
    EXPECT_CALL(fake_drivefs, GetSyncingPaths(_))
        .WillOnce(RunOnceCallback<0>(drive::FileError::FILE_ERROR_NOT_FOUND,
                                     mock_paths));
    const auto& syncing_paths = GetSyncingPaths();
    EXPECT_THAT(syncing_paths,
                AllOf(SyncPathError(PageHandler::GetSyncPathError::kFailed),
                      SyncingPaths(IsEmpty())));
  }

  {
    // Paths returned not parented at MyFiles should be ignored.
    auto [mock_paths, expected_paths] = CreateExpectedPaths({{"baz"}});
    mock_paths.push_back(base::FilePath("/ignore/dir"));
    EXPECT_CALL(fake_drivefs, GetSyncingPaths(_))
        .WillOnce(
            RunOnceCallback<0>(drive::FileError::FILE_ERROR_OK, mock_paths));
    const auto& syncing_paths = GetSyncingPaths();
    EXPECT_THAT(
        syncing_paths,
        AllOf(SyncPathError(PageHandler::GetSyncPathError::kSuccess),
              SyncingPaths(AllOf(SizeIs(1), MojoFilePaths(ElementsAreArray(
                                                expected_paths))))));
  }
}

IN_PROC_BROWSER_TEST_F(ManageMirrorSyncDialogTest,
                       GetSyncingPathsMirrorSyncDisabled) {
  SetUpMirrorSyncAndDialog(/*enabled=*/false);

  {
    // MirrorSync not enabled should return kServiceUnavailable and empty paths.
    const auto& syncing_paths = GetSyncingPaths();
    EXPECT_THAT(
        syncing_paths,
        AllOf(SyncPathError(PageHandler::GetSyncPathError::kServiceUnavailable),
              SyncingPaths(IsEmpty())));
  }
}

}  // namespace ash