blob: 8e6dd159f336993e8f666be9c029ecaa6778afe9 [file] [log] [blame]
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include <string>
#include <string_view>
#include <tuple>
#include "base/notreached.h"
#include "base/strings/stringprintf.h"
#include "base/strings/utf_string_conversions.h"
#include "base/test/scoped_feature_list.h"
#include "base/types/strong_alias.h"
#include "chrome/browser/extensions/chrome_test_extension_loader.h"
#include "chrome/browser/extensions/install_verifier.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/supervised_user/supervised_user_test_util.h"
#include "chrome/browser/ui/extensions/extensions_dialogs.h"
#include "chrome/browser/ui/supervised_user/parent_permission_dialog.h"
#include "chrome/browser/ui/tabs/tab_strip_model_observer.h"
#include "chrome/test/interaction/interactive_browser_test.h"
#include "chrome/test/supervised_user/family_live_test.h"
#include "chrome/test/supervised_user/family_member.h"
#include "components/prefs/pref_service.h"
#include "components/supervised_user/core/common/features.h"
#include "components/supervised_user/core/common/pref_names.h"
#include "content/public/test/browser_test.h"
#include "extensions/test/test_extension_dir.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "ui/base/interaction/element_identifier.h"
namespace supervised_user {
namespace {
static constexpr std::string_view kChromeManageExternsionsUrl =
"chrome://extensions/";
// State of a Family Link switch.
enum class FamilyLinkSwitchState : int {
kEnabled = 0,
kDisabled,
};
// Family Link switch that governs the handling of extensions for SU.
enum class ExtensionHandlingMode : int {
kExtensionsGovernedByPermissionsSwitch = 0,
kExtensionsGovernedByExtensionsSwitch,
};
// Family Link swiches from Advance Settings.
enum class FamilyLinkSwitch : int {
kPermissionsSwitch = 0,
kExtensionsSwitch,
};
constexpr const char* kFamilyLinkSwitchStateToString[2] = {"true", "false"};
using BoolPreferenceStateObserver = ui::test::PollingStateObserver<bool>;
DEFINE_LOCAL_STATE_IDENTIFIER_VALUE(BoolPreferenceStateObserver,
kBoolPermissionsPreferenceObserver);
DEFINE_LOCAL_STATE_IDENTIFIER_VALUE(BoolPreferenceStateObserver,
kBoolExtensionsPreferenceObserver);
// TODO(b/321242366): Consider moving to helper class.
// Checks if a page title matches the given regexp in ecma script dialect.
InteractiveBrowserTestApi::StateChange PageWithMatchingTitle(
std::string_view title_regexp) {
DEFINE_LOCAL_CUSTOM_ELEMENT_EVENT_TYPE(kStateChange);
InteractiveBrowserTestApi::StateChange state_change;
state_change.type =
InteractiveBrowserTestApi::StateChange::Type::kConditionTrue;
state_change.event = kStateChange;
state_change.test_function = base::StringPrintf(R"js(
() => /%s/.test(document.title)
)js",
title_regexp.data());
state_change.continue_across_navigation = true;
return state_change;
}
// Test the behavior of handling extensions for supervised users when parental
// controls apply on extensions (by default on Chrome OS, depending on the
// kEnableExtensionsPermissionsForSupervisedUsersOnDesktop feature on
// Win/Mac/Linux).
class SupervisedUserExtensionsParentalControlsUiTest
: public InteractiveBrowserTestT<FamilyLiveTest>,
public testing::WithParamInterface<std::tuple<
FamilyIdentifier,
/*permissions_switch_state=*/FamilyLinkSwitchState,
/*extensions_switch_state=*/FamilyLinkSwitchState,
// Depending on the ExtensionHandlingMode only one switch
// should affect the behaviour of supervised user's extensions.
// Toggling the other switch should have no effect to the result.
/*extensions_handling_mode=*/ExtensionHandlingMode>> {
public:
SupervisedUserExtensionsParentalControlsUiTest()
: InteractiveBrowserTestT<FamilyLiveTest>(
/*family_identifier=*/std::get<0>(GetParam()),
/*extra_enabled_hosts=*/std::vector<std::string>()) {
std::vector<base::test::FeatureRef> enabled_features;
std::vector<base::test::FeatureRef> disabled_features;
if (GetExtensionHandlingMode() ==
ExtensionHandlingMode::kExtensionsGovernedByExtensionsSwitch) {
enabled_features.push_back(
kEnableSupervisedUserSkipParentApprovalToInstallExtensions);
} else {
disabled_features.push_back(
kEnableSupervisedUserSkipParentApprovalToInstallExtensions);
}
#if BUILDFLAG(IS_LINUX) || BUILDFLAG(IS_MAC) || BUILDFLAG(IS_WIN)
// Enable extensions parental controls.
enabled_features.push_back(
kEnableExtensionsPermissionsForSupervisedUsersOnDesktop);
#endif
feature_list_.InitWithFeatures(enabled_features, disabled_features);
}
protected:
// Parent navigates to FL control page and waits for it to load.
auto ParentOpensControlPage(ui::ElementIdentifier kParentTab,
const GURL& gurl) {
return Steps(NavigateWebContents(kParentTab, gurl),
WaitForWebContentsReady(kParentTab, gurl));
}
// Child tries to enable a disabled extension (which is pending parent
// approval) by clicking at the extension's toggle.
auto ChildClicksEnableExtensionIfExtensionDisbaled(
ui::ElementIdentifier kChildTab,
bool expected_extension_enabled) {
return Steps(ExecuteJs(kChildTab,
base::StringPrintf(
R"js(
() => {
const view_manager =
document.querySelector("extensions-manager").shadowRoot
.querySelector("#container").querySelector("#viewManager");
if (!view_manager) {
throw Error("Path to view_manager element is invalid.");
}
const container = view_manager.querySelector("#items-list")
.shadowRoot.querySelector("#container");
if (!container) {
throw Error("Path to container element is invalid.");
}
const count = container.querySelectorAll("extensions-item").length;
if (count !== 1) {
throw Error("Encountered unexpected number of extensions: " + count);
}
const extn = container.querySelectorAll("extensions-item")[0];
if (!extn) {
throw Error("Path to extension element is invalid.");
}
const toggle = extn.shadowRoot.querySelector("#enableToggle");
if (!toggle) {
throw Error("Path to extension toggle is invalid.");
}
if (toggle.ariaPressed !== "%s") {
throw Error("Extension toggle in unexpected state: " + toggle.ariaPressed);
}
if (toggle.ariaPressed == "false") {
toggle.click();
}
}
)js",
expected_extension_enabled ? "true" : "false")),
Log("Child inspected extension toggle."));
}
// Installs programmatically (not through the UI) an extension for the given
// user.
void InstallExtension(const std::string_view& name, Profile* profile) {
extensions::TestExtensionDir extension_dir;
extension_dir.WriteManifest(base::StringPrintf(
R"({
"name": "%s",
"manifest_version": 3,
"version": "0.1"
})",
name.data()));
extensions::ChromeTestExtensionLoader extension_loader(profile);
extension_loader.set_ignore_manifest_warnings(true);
extension_loader.LoadExtension(extension_dir.Pack());
}
// Parent toggles the "Permissions" switch in FL, if it does not have
// the desired value.
auto ParentSetsPermissionsSwitch(ui::ElementIdentifier kParentTab,
FamilyLinkSwitchState target_state) {
return Steps(ExecuteJs(
kParentTab,
base::StringPrintf(
R"js(
() => {
const button = document.querySelector('[aria-label="Toggle permissions for sites, apps and extensions"]')
if (!button) {
throw Error("'Permissions' toggle not found.");
}
if (button.ariaChecked != "%s") {
button.click();
}
}
)js",
kFamilyLinkSwitchStateToString[static_cast<int>(target_state)])));
}
// Polls the given preference value in Chrome.
auto PollPreference(ui::test::StateIdentifier<BoolPreferenceStateObserver>
preference_observer,
std::string_view preference) {
return Steps(PollState(preference_observer, [this, preference]() {
return child().browser()->profile()->GetPrefs()->GetBoolean(preference);
}));
}
FamilyLinkSwitch GetExtensionGoverningSwitch() {
return GetExtensionHandlingMode() ==
ExtensionHandlingMode::kExtensionsGovernedByPermissionsSwitch
? FamilyLinkSwitch::kPermissionsSwitch
: FamilyLinkSwitch::kExtensionsSwitch;
}
auto ParentsSetsFamilyLinkSwitchAndWaitsForChromeToReceiveIt(
FamilyLinkSwitch family_link_switch,
FamilyLinkSwitchState switch_state,
ui::ElementIdentifier kParentControlsTab) {
bool pref_target_value =
switch_state == FamilyLinkSwitchState::kEnabled ? true : false;
if (family_link_switch == FamilyLinkSwitch::kPermissionsSwitch) {
// Parent sets the FL switch "Permissions" to ON.
// TODO(b/303401498): Use chrome test state seeding rpc.
return Steps(
ParentSetsPermissionsSwitch(kParentControlsTab, switch_state),
WaitForState(kBoolPermissionsPreferenceObserver, pref_target_value));
}
// TODO(b/318069335): Setting the corresponding preference directly in
// Chrome, until the FL "Extensions" switch is fully released. Once
// available, set it via UI or chrome test state seeding rpc.
CHECK(family_link_switch == FamilyLinkSwitch::kExtensionsSwitch);
return Steps(
Do([this, pref_target_value]() -> void {
supervised_user_test_util::
SetSkipParentApprovalToInstallExtensionsPref(
child().browser()->profile(), pref_target_value);
}),
WaitForState(kBoolExtensionsPreferenceObserver, pref_target_value));
}
ui::ElementIdentifier GetTargetUIElement() {
if (GetExtensionHandlingMode() ==
ExtensionHandlingMode::kExtensionsGovernedByPermissionsSwitch) {
// Depending on the "Permissions" switch's value either the "Parent
// Approval Dialog" (switch ON) or the "Extensions Blocked by Parent"
// error message will appear at the end.
return GetPermissionsSwitchTargetState() ==
FamilyLinkSwitchState::kEnabled
? ParentPermissionDialog::kDialogViewIdForTesting
: extensions::kParentBlockedDialogMessage;
}
// If goverved by extensions:
CHECK(GetExtensionHandlingMode() ==
ExtensionHandlingMode::kExtensionsGovernedByExtensionsSwitch);
CHECK(GetExtensionsSwitchTargetState() == FamilyLinkSwitchState::kDisabled);
// Parent approval dialog should appear.
return ParentPermissionDialog::kDialogViewIdForTesting;
}
auto GetSwitchStateForRequestingParentApproval() {
switch (GetExtensionGoverningSwitch()) {
case (FamilyLinkSwitch::kPermissionsSwitch): {
return FamilyLinkSwitchState::kEnabled;
}
case (FamilyLinkSwitch::kExtensionsSwitch): {
return FamilyLinkSwitchState::kDisabled;
}
default: {
NOTREACHED_NORETURN();
}
}
}
auto CheckForParentDialogIfExtensionDisabled(
bool is_expected_extension_enabled) {
if (is_expected_extension_enabled) {
// No dialog appears in this case.
return Steps();
}
auto target_ui_element_id = GetTargetUIElement();
return Steps(
WaitForShow(target_ui_element_id),
Log(base::StringPrintf("The %s appears.",
(target_ui_element_id ==
ParentPermissionDialog::kDialogViewIdForTesting)
? "parent approval dialog"
: "blocked extension message")));
}
FamilyLinkSwitchState GetPermissionsSwitchTargetState() {
return std::get<1>(GetParam());
}
FamilyLinkSwitchState GetExtensionsSwitchTargetState() {
return std::get<2>(GetParam());
}
ExtensionHandlingMode GetExtensionHandlingMode() {
return std::get<3>(GetParam());
}
private:
base::test::ScopedFeatureList feature_list_;
};
IN_PROC_BROWSER_TEST_P(SupervisedUserExtensionsParentalControlsUiTest,
ChildTogglesExtensionMissingParentApproval) {
extensions::ScopedInstallVerifierBypassForTest install_verifier_bypass;
DEFINE_LOCAL_ELEMENT_IDENTIFIER_VALUE(kChildElementId);
DEFINE_LOCAL_ELEMENT_IDENTIFIER_VALUE(kParentControlsTab);
const int child_tab_index = 0;
const int parent_tab_index = 0;
// The extensions should be disabled (pending parent approval) in all cases,
// expect when the new "Extensions" FL switch is enabled and is used
// in Chrome to manage extensions.
const bool is_expected_extension_enabled =
GetExtensionHandlingMode() ==
ExtensionHandlingMode::kExtensionsGovernedByExtensionsSwitch &&
GetExtensionsSwitchTargetState() == FamilyLinkSwitchState::kEnabled;
TurnOnSyncFor(head_of_household());
TurnOnSyncFor(child());
RunTestSequence(
InstrumentTab(kParentControlsTab, parent_tab_index,
head_of_household().browser()),
ParentOpensControlPage(kParentControlsTab,
head_of_household().GetPermissionsUrlFor(child())),
PollPreference(kBoolExtensionsPreferenceObserver,
prefs::kSkipParentApprovalToInstallExtensions),
PollPreference(kBoolPermissionsPreferenceObserver,
prefs::kSupervisedUserExtensionsMayRequestPermissions),
// Set the corresponding FL switch in "Require parent approval" mode.
ParentsSetsFamilyLinkSwitchAndWaitsForChromeToReceiveIt(
GetExtensionGoverningSwitch(),
GetSwitchStateForRequestingParentApproval(), kParentControlsTab),
Log("Given parental configuration that allows extensions."));
InstallExtension("A Extension", child().browser()->profile());
RunTestSequence(InAnyContext(Steps(
Log("Given an installed disabled exetension."),
// Parent sets both the FL Permissions and Extensions switches.
// Only one of them impacts the handling of supervised user extensions.
ParentsSetsFamilyLinkSwitchAndWaitsForChromeToReceiveIt(
FamilyLinkSwitch::kPermissionsSwitch,
GetPermissionsSwitchTargetState(), kParentControlsTab),
ParentsSetsFamilyLinkSwitchAndWaitsForChromeToReceiveIt(
FamilyLinkSwitch::kExtensionsSwitch, GetExtensionsSwitchTargetState(),
kParentControlsTab),
// Child navigates to the extensions page and tries to enable the
// extension, if it is disabled.
Log("When child visits the extensions management page."),
InstrumentTab(kChildElementId, child_tab_index, child().browser()),
NavigateWebContents(kChildElementId, GURL(kChromeManageExternsionsUrl)),
WaitForStateChange(kChildElementId, PageWithMatchingTitle("Extensions")),
Log("When child tries to enable the extension."),
ChildClicksEnableExtensionIfExtensionDisbaled(
kChildElementId, is_expected_extension_enabled),
// If the extension is not already enabled, check that the expect UI
// dialog appears.
CheckForParentDialogIfExtensionDisabled(is_expected_extension_enabled))));
}
INSTANTIATE_TEST_SUITE_P(
All,
SupervisedUserExtensionsParentalControlsUiTest,
testing::Combine(
testing::Values(FamilyIdentifier("FAMILY_DMA_ELIGIBILE_NO_CONSENT"),
FamilyIdentifier("FAMILY_DMA_ELIGIBLE_WITH_CONSENT"),
FamilyIdentifier("FAMILY_DMA_INELIGIBLE")),
/*permissions_switch_target_value=*/
testing::Values(FamilyLinkSwitchState::kEnabled,
FamilyLinkSwitchState::kDisabled),
/*extensions_switch_target_value==*/
testing::Values(
// TODO(b/321239324): Parametrize with Extensions switch ON once the
// handling of extensions on switch flipping is added.
FamilyLinkSwitchState::kDisabled),
/*extensions_handling_mode=*/
testing::Values(
ExtensionHandlingMode::kExtensionsGovernedByPermissionsSwitch,
ExtensionHandlingMode::kExtensionsGovernedByExtensionsSwitch)),
[](const auto& info) {
return std::string(std::get<0>(info.param)->data()) +
std::string(
(std::get<1>(info.param) == FamilyLinkSwitchState::kEnabled
? "WithPermissionsOn"
: "WithPermissionsOff")) +
std::string(
(std::get<2>(info.param) == FamilyLinkSwitchState::kEnabled
? "WithExtensionsOn"
: "WithExtensionsOff")) +
std::string((std::get<3>(info.param) ==
ExtensionHandlingMode::
kExtensionsGovernedByPermissionsSwitch
? "ManagedByPermissionsSwitch"
: "ManagedByExtensionsSwitch"));
});
} // namespace
} // namespace supervised_user