chromium/ash/webui/diagnostics_ui/backend/session_log_handler_unittest.cc

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

#include "ash/webui/diagnostics_ui/backend/session_log_handler.h"

#include <memory>
#include <string>
#include <utility>

#include "ash/public/cpp/holding_space/mock_holding_space_client.h"
#include "ash/system/diagnostics/diagnostics_browser_delegate.h"
#include "ash/system/diagnostics/diagnostics_log_controller.h"
#include "ash/system/diagnostics/log_test_helpers.h"
#include "ash/system/diagnostics/networking_log.h"
#include "ash/system/diagnostics/routine_log.h"
#include "ash/system/diagnostics/telemetry_log.h"
#include "ash/test/ash_test_base.h"
#include "ash/webui/diagnostics_ui/mojom/system_data_provider.mojom.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/files/scoped_temp_dir.h"
#include "base/functional/bind.h"
#include "base/memory/raw_ptr.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_util.h"
#include "base/test/bind.h"
#include "base/test/task_environment.h"
#include "base/test/test_simple_task_runner.h"
#include "base/values.h"
#include "chromeos/ash/components/test/ash_test_suite.h"
#include "content/public/browser/web_contents.h"
#include "content/public/test/test_web_ui.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "ui/base/resource/resource_bundle.h"
#include "ui/gfx/native_widget_types.h"
#include "ui/shell_dialogs/select_file_dialog.h"
#include "ui/shell_dialogs/select_file_dialog_factory.h"
#include "ui/shell_dialogs/select_file_policy.h"
#include "ui/shell_dialogs/selected_file_info.h"
#include "url/gurl.h"

namespace ash::diagnostics {
namespace {

constexpr char kHandlerFunctionName[] = "handlerFunctionName";

mojom::SystemInfoPtr CreateSystemInfoPtr(const std::string& board_name,
                                         const std::string& marketing_name,
                                         const std::string& cpu_model,
                                         uint32_t total_memory_kib,
                                         uint16_t cpu_threads_count,
                                         uint32_t cpu_max_clock_speed_khz,
                                         bool has_battery,
                                         const std::string& milestone_version,
                                         const std::string& full_version) {
  auto version_info = mojom::VersionInfo::New(milestone_version, full_version);
  auto device_capabilities = mojom::DeviceCapabilities::New(has_battery);

  auto system_info = mojom::SystemInfo::New(
      board_name, marketing_name, cpu_model, total_memory_kib,
      cpu_threads_count, cpu_max_clock_speed_khz, std::move(version_info),
      std::move(device_capabilities));
  return system_info;
}

std::vector<std::string> GetCombinedLogContents(
    const base::FilePath& log_path) {
  std::string contents;
  base::ReadFileToString(log_path, &contents);
  return GetLogLines(contents);
}

// A fake DiagnosticsBrowserDelegate.
class FakeDiagnosticsBrowserDelegate : public DiagnosticsBrowserDelegate {
 public:
  FakeDiagnosticsBrowserDelegate() = default;
  ~FakeDiagnosticsBrowserDelegate() override = default;

  base::FilePath GetActiveUserProfileDir() override {
    base::ScopedTempDir tmp_dir;
    EXPECT_TRUE(tmp_dir.CreateUniqueTempDir());
    return tmp_dir.GetPath();
  }
};

// A fake ui::SelectFileDialog.
class TestSelectFileDialog : public ui::SelectFileDialog {
 public:
  TestSelectFileDialog(Listener* listener,
                       std::unique_ptr<ui::SelectFilePolicy> policy,
                       base::FilePath selected_path)
      : ui::SelectFileDialog(listener, std::move(policy)),
        selected_path_(selected_path) {}

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

 protected:
  void SelectFileImpl(Type type,
                      const std::u16string& title,
                      const base::FilePath& default_path,
                      const FileTypeInfo* file_types,
                      int file_type_index,
                      const base::FilePath::StringType& default_extension,
                      gfx::NativeWindow owning_window,
                      const GURL* caller) override {
    if (selected_path_.empty()) {
      listener_->FileSelectionCanceled();
      return;
    }

    listener_->FileSelected(ui::SelectedFileInfo(selected_path_), /*index=*/0);
  }

  bool IsRunning(gfx::NativeWindow owning_window) const override {
    return true;
  }
  void ListenerDestroyed() override { listener_ = nullptr; }
  bool HasMultipleFileTypeChoicesImpl() override { return false; }

 private:
  ~TestSelectFileDialog() override = default;

  // The simulatd file path selected by the user.
  base::FilePath selected_path_;
};

// A factory associated with the artificial file picker.
class TestSelectFileDialogFactory : public ui::SelectFileDialogFactory {
 public:
  explicit TestSelectFileDialogFactory(base::FilePath selected_path)
      : selected_path_(selected_path) {}

  ui::SelectFileDialog* Create(
      ui::SelectFileDialog::Listener* listener,
      std::unique_ptr<ui::SelectFilePolicy> policy) override {
    return new TestSelectFileDialog(listener, std::move(policy),
                                    selected_path_);
  }

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

 private:
  // The simulated file path selected by the user.
  base::FilePath selected_path_;
};

// Test class using NoSessionAshTestBase to ensure shell is available for
// tests requiring DiagnosticsLogController singleton.
class SessionLogHandlerTest : public NoSessionAshTestBase {
 public:
  SessionLogHandlerTest()
      : NoSessionAshTestBase(
            base::test::TaskEnvironment::TimeSource::MOCK_TIME),
        task_runner_(new base::TestSimpleTaskRunner()) {}
  ~SessionLogHandlerTest() override = default;

  void SetUp() override {
    EXPECT_TRUE(temp_dir_.CreateUniqueTempDir());
    // Setup to ensure ash::Shell can configure for tests.
    ui::ResourceBundle::CleanupSharedInstance();
    AshTestSuite::LoadTestResources();
    NoSessionAshTestBase::SetUp();
    DiagnosticsLogController::Initialize(
        std::make_unique<FakeDiagnosticsBrowserDelegate>());
    session_log_handler_ = std::make_unique<diagnostics::SessionLogHandler>(
        base::BindRepeating(
            [](content::WebContents*) -> std::unique_ptr<ui::SelectFilePolicy> {
              return nullptr;
            }),
        /*telemetry_log*/ nullptr, /*routine_log*/ nullptr,
        /*networking_log*/ nullptr, &holding_space_client_);
    session_log_handler_->SetWebUIForTest(&web_ui_);
    session_log_handler_->RegisterMessages();
    session_log_handler_->SetTaskRunnerForTesting(task_runner_);

    // This test suite does not check `HoldingSpaceItem` ids, so there is no
    // need to save the mock id.
    ON_CALL(holding_space_client(), AddItemOfType)
        .WillByDefault(testing::ReturnRef(base::EmptyString()));

    // Call handler to enable Javascript.
    base::Value::List args;
    web_ui_.HandleReceivedMessage("initialize", args);
  }

  void TearDown() override {
    task_runner_.reset();
    task_environment()->RunUntilIdle();
    ui::SelectFileDialog::SetFactory(nullptr);

    NoSessionAshTestBase::TearDown();
  }

  void RunTasks() { task_runner_->RunPendingTasks(); }

  const content::TestWebUI::CallData& CallDataAtIndex(size_t index) {
    return *web_ui_.call_data()[index];
  }

  testing::NiceMock<ash::MockHoldingSpaceClient>& holding_space_client() {
    return holding_space_client_;
  }

 protected:
  // Task runner for tasks posted by save session log handler.
  scoped_refptr<base::TestSimpleTaskRunner> task_runner_;
  content::TestWebUI web_ui_;
  std::unique_ptr<SessionLogHandler> session_log_handler_;
  base::ScopedTempDir temp_dir_;
  testing::NiceMock<ash::MockHoldingSpaceClient> holding_space_client_;
};

}  // namespace

TEST_F(SessionLogHandlerTest, SaveSessionLog) {
  // Run until idle to finish necessary setup.
  task_environment()->RunUntilIdle();

  base::RunLoop run_loop;
  // Populate routine log
  DiagnosticsLogController::Get()->GetRoutineLog().LogRoutineStarted(
      mojom::RoutineType::kCpuStress);
  task_environment()->RunUntilIdle();

  // Populate telemetry log
  const std::string expected_board_name = "board_name";
  const std::string expected_marketing_name = "marketing_name";
  const std::string expected_cpu_model = "cpu_model";
  const uint32_t expected_total_memory_kib = 1234;
  const uint16_t expected_cpu_threads_count = 5678;
  const uint32_t expected_cpu_max_clock_speed_khz = 91011;
  const bool expected_has_battery = true;
  const std::string expected_milestone_version = "M99";
  const std::string expected_full_version = "M99.1234.5.6";
  mojom::SystemInfoPtr test_info = CreateSystemInfoPtr(
      expected_board_name, expected_marketing_name, expected_cpu_model,
      expected_total_memory_kib, expected_cpu_threads_count,
      expected_cpu_max_clock_speed_khz, expected_has_battery,
      expected_milestone_version, expected_full_version);

  DiagnosticsLogController::Get()->GetTelemetryLog().UpdateSystemInfo(
      std::move(test_info));

  // Select file
  base::FilePath log_path = temp_dir_.GetPath().AppendASCII("test_path");
  ui::SelectFileDialog::SetFactory(
      std::make_unique<TestSelectFileDialogFactory>(log_path));
  base::Value::List args;
  args.Append(kHandlerFunctionName);
  session_log_handler_->SetLogCreatedClosureForTest(run_loop.QuitClosure());
  web_ui_.HandleReceivedMessage("saveSessionLog", args);
  run_loop.RunUntilIdle();
  const std::string expected_system_log_header = "=== System ===";
  const std::string expected_system_info_section_name = "--- System Info ---";
  const std::string expected_snapshot_time_prefix = "Snapshot Time: ";
  RunTasks();
  const std::vector<std::string> log_lines = GetCombinedLogContents(log_path);
  ASSERT_EQ(18u, log_lines.size());
  EXPECT_EQ(expected_system_log_header, log_lines[0]);
  EXPECT_EQ(expected_system_info_section_name, log_lines[1]);
  EXPECT_GT(log_lines[2].size(), expected_snapshot_time_prefix.size());
  EXPECT_TRUE(base::StartsWith(log_lines[2], expected_snapshot_time_prefix));
  EXPECT_EQ("Board Name: " + expected_board_name, log_lines[3]);
  EXPECT_EQ("Marketing Name: " + expected_marketing_name, log_lines[4]);
  EXPECT_EQ("CpuModel Name: " + expected_cpu_model, log_lines[5]);
  EXPECT_EQ(
      "Total Memory (kib): " + base::NumberToString(expected_total_memory_kib),
      log_lines[6]);
  EXPECT_EQ(
      "Thread Count:  " + base::NumberToString(expected_cpu_threads_count),
      log_lines[7]);
  EXPECT_EQ("Cpu Max Clock Speed (kHz):  " +
                base::NumberToString(expected_cpu_max_clock_speed_khz),
            log_lines[8]);
  EXPECT_EQ("Version: " + expected_full_version, log_lines[9]);
  EXPECT_EQ("Has Battery: true", log_lines[10]);

  const std::string expected_routine_log_header = "--- Test Routines ---";
  EXPECT_EQ(expected_routine_log_header, log_lines[11]);

  const std::vector<std::string> first_routine_log_line_contents =
      GetLogLineContents(log_lines[12]);
  ASSERT_EQ(3u, first_routine_log_line_contents.size());
  // first_routine_log_line_contents[0] is ignored because it's a timestamp.
  EXPECT_EQ("CpuStress", first_routine_log_line_contents[1]);
  EXPECT_EQ("Started", first_routine_log_line_contents[2]);

  // Networking log contents.
  EXPECT_EQ("=== Networking ===", log_lines[13]);
  EXPECT_EQ("--- Network Info ---", log_lines[14]);
  EXPECT_EQ("--- Test Routines ---", log_lines[15]);
  EXPECT_EQ("No routines of this type were run in the session.", log_lines[16]);
  EXPECT_EQ("--- Network Events ---", log_lines[17]);
}

// Validates behavior when log controller is used to generate session log.
TEST_F(SessionLogHandlerTest, SaveHeaderOnlySessionLog) {
  base::RunLoop run_loop;

  // Simulate select file
  base::FilePath log_path = temp_dir_.GetPath().AppendASCII("test_path");
  ui::SelectFileDialog::SetFactory(
      std::make_unique<TestSelectFileDialogFactory>(log_path));
  base::Value::List args;
  args.Append(kHandlerFunctionName);
  session_log_handler_->SetLogCreatedClosureForTest(run_loop.QuitClosure());
  web_ui_.HandleReceivedMessage("saveSessionLog", args);
  RunTasks();

  const std::vector<std::string> log_lines = GetCombinedLogContents(log_path);
  ASSERT_EQ(8u, log_lines.size());

  // Empty system data log.
  const std::string expected_system_header = "=== System ===";
  EXPECT_EQ(expected_system_header, log_lines[0]);
  const std::string expected_routine_header = "--- Test Routines ---";
  EXPECT_EQ(expected_routine_header, log_lines[1]);
  const std::string expected_no_routine_msg =
      "No routines of this type were run in the session.";
  EXPECT_EQ(expected_no_routine_msg, log_lines[2]);

  // Empty network data log.
  const std::string expected_network_header = "=== Networking ===";
  EXPECT_EQ(expected_network_header, log_lines[3]);
  const std::string expected_network_info_header = "--- Network Info ---";
  EXPECT_EQ(expected_network_info_header, log_lines[4]);
  EXPECT_EQ(expected_routine_header, log_lines[5]);
  EXPECT_EQ(expected_no_routine_msg, log_lines[6]);
  const std::string expected_network_events_header = "--- Network Events ---";
  EXPECT_EQ(expected_network_events_header, log_lines[7]);
}

// Validates that invoking the saveSessionLog Web UI event opens the
// select dialog. Choosing a directory should return that the operation
// was successful.
TEST_F(SessionLogHandlerTest, SelectDirectory) {
  base::FilePath log_path = temp_dir_.GetPath().AppendASCII("test_path");
  ui::SelectFileDialog::SetFactory(
      std::make_unique<TestSelectFileDialogFactory>(log_path));

  const size_t call_data_count_before_call = web_ui_.call_data().size();
  base::Value::List args;
  args.Append(kHandlerFunctionName);
  base::RunLoop run_loop;
  session_log_handler_->SetLogCreatedClosureForTest(run_loop.QuitClosure());
  web_ui_.HandleReceivedMessage("saveSessionLog", args);
  RunTasks();
  run_loop.RunUntilIdle();

  EXPECT_EQ(call_data_count_before_call + 1u, web_ui_.call_data().size());
  const content::TestWebUI::CallData& call_data =
      CallDataAtIndex(call_data_count_before_call);
  EXPECT_EQ("cr.webUIResponse", call_data.function_name());
  EXPECT_EQ("handlerFunctionName", call_data.arg1()->GetString());
  EXPECT_TRUE(/*success=*/call_data.arg2()->GetBool());
}

TEST_F(SessionLogHandlerTest, CancelDialog) {
  // A dialog returning an empty file path simulates the user closing the
  // dialog without selecting a path.
  ui::SelectFileDialog::SetFactory(
      std::make_unique<TestSelectFileDialogFactory>(base::FilePath()));

  const size_t call_data_count_before_call = web_ui_.call_data().size();
  base::Value::List args;
  args.Append(kHandlerFunctionName);
  web_ui_.HandleReceivedMessage("saveSessionLog", args);
  RunTasks();

  EXPECT_EQ(call_data_count_before_call + 1u, web_ui_.call_data().size());
  const content::TestWebUI::CallData& call_data =
      CallDataAtIndex(call_data_count_before_call);
  EXPECT_EQ("cr.webUIResponse", call_data.function_name());
  EXPECT_EQ("handlerFunctionName", call_data.arg1()->GetString());
  EXPECT_FALSE(/*success=*/call_data.arg2()->GetBool());
}

// Validates that saving a session log results in the session log file being
// added to the holding space.
TEST_F(SessionLogHandlerTest, AddToHoldingSpace) {
  base::FilePath log_path = temp_dir_.GetPath().AppendASCII("test_path");
  ui::SelectFileDialog::SetFactory(
      std::make_unique<TestSelectFileDialogFactory>(log_path));
  base::Value::List args;
  args.Append(kHandlerFunctionName);

  EXPECT_CALL(holding_space_client(),
              AddItemOfType(HoldingSpaceItem::Type::kDiagnosticsLog,
                            testing::Eq(log_path)));

  base::RunLoop run_loop;
  session_log_handler_->SetLogCreatedClosureForTest(run_loop.QuitClosure());
  web_ui_.HandleReceivedMessage("saveSessionLog", args);
  RunTasks();
  run_loop.RunUntilIdle();
}

// Validates that the lifecycle clean up tasks are completed if the select file
// dialog is open when session_log_handler is destroyed.
TEST_F(SessionLogHandlerTest, CleanUpDialogOnDeconstruct) {
  base::FilePath log_path = temp_dir_.GetPath().AppendASCII("test_path");
  ui::SelectFileDialog::SetFactory(
      std::make_unique<TestSelectFileDialogFactory>(log_path));
  base::Value::List args;
  args.Append(kHandlerFunctionName);
  base::RunLoop run_loop;

  session_log_handler_->SetLogCreatedClosureForTest(run_loop.QuitClosure());
  web_ui_.HandleReceivedMessage("saveSessionLog", args);
  EXPECT_NO_FATAL_FAILURE(session_log_handler_.reset());
  EXPECT_NO_FATAL_FAILURE(task_runner_.reset());
  EXPECT_NO_FATAL_FAILURE(run_loop.RunUntilIdle());
}

// Validates CreateSessionLog task does not trigger a Use-After-Free error
// when SessionLogHandler is destroyed before task is run. See crbug/1328708.
TEST_F(SessionLogHandlerTest, NoUseAfterFree) {
  base::FilePath log_path = temp_dir_.GetPath().AppendASCII("test_path");
  ui::SelectFileDialog::SetFactory(
      std::make_unique<TestSelectFileDialogFactory>(log_path));
  base::Value::List args;
  args.Append(kHandlerFunctionName);
  base::RunLoop run_loop;

  session_log_handler_->SetLogCreatedClosureForTest(
      base::BindLambdaForTesting([]() { NOTREACHED(); }));
  EXPECT_EQ(0u, task_runner_->NumPendingTasks());
  web_ui_.HandleReceivedMessage("saveSessionLog", args);
  EXPECT_EQ(1u, task_runner_->NumPendingTasks());
  EXPECT_NO_FATAL_FAILURE(session_log_handler_.reset());
  task_runner_->RunUntilIdle();
  EXPECT_EQ(0u, task_runner_->NumPendingTasks());
  EXPECT_NO_FATAL_FAILURE(task_runner_->RunUntilIdle());
}

}  // namespace ash::diagnostics