Adds new function MaybeShowSamlPasswordExpiryNotification(Profile*)

Functions already exist to simply show or hide the notification.
This new function checks the existing information for the given profile,
and decides whether to do nothing / hide a notification, show a
notification now, or show one at some point in the distant future.

It is possible to call this function multiple times (for instance,
every time a user logs in) without it creating multiple tasks to
show multiple notifications in the distant future. However, calling
the function again after the user has dismissed the notification will
result in it being reshown, so it shouldn't be called every minute.

So far it is not called at all - calling it (and making sure it is
not called too often) will be done in a follow up CL.

Bug: 930109
Change-Id: I859f0bfa999e2e262911c6fe3ba4c43359e24fdc
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1611980
Reviewed-by: Roman Sorokin [CET] <rsorokin@chromium.org>
Commit-Queue: A Olsen <olsen@chromium.org>
Cr-Commit-Position: refs/heads/master@{#660364}
diff --git a/chrome/browser/chromeos/login/saml/saml_password_expiry_notification.cc b/chrome/browser/chromeos/login/saml/saml_password_expiry_notification.cc
index 49456b1..b54f02c 100644
--- a/chrome/browser/chromeos/login/saml/saml_password_expiry_notification.cc
+++ b/chrome/browser/chromeos/login/saml/saml_password_expiry_notification.cc
@@ -8,14 +8,23 @@
 #include "base/bind.h"
 #include "base/strings/string16.h"
 #include "base/strings/string_util.h"
+#include "base/task/post_task.h"
+#include "base/task/task_traits.h"
+#include "chrome/browser/browser_process.h"
 #include "chrome/browser/notifications/notification_common.h"
 #include "chrome/browser/notifications/notification_display_service.h"
 #include "chrome/browser/notifications/notification_display_service_factory.h"
 #include "chrome/browser/profiles/profile.h"
+#include "chrome/browser/profiles/profile_manager.h"
 #include "chrome/browser/ui/ash/chrome_new_window_client.h"
+#include "chrome/common/pref_names.h"
 #include "chrome/common/webui_url_constants.h"
+#include "chromeos/login/auth/saml_password_attributes.h"
 #include "chromeos/strings/grit/chromeos_strings.h"
+#include "components/prefs/pref_service.h"
 #include "components/vector_icons/vector_icons.h"
+#include "content/public/browser/browser_task_traits.h"
+#include "content/public/browser/browser_thread.h"
 #include "ui/base/l10n/l10n_util.h"
 #include "ui/message_center/public/cpp/notification.h"
 #include "ui/message_center/public/cpp/notification_delegate.h"
@@ -95,9 +104,108 @@
   return base::JoinString(body_lines, *kLineSeparator);
 }
 
+// A time delta of length zero.
+const base::TimeDelta kZeroTimeDelta = base::TimeDelta();
+// A time delta of length one hour.
+const base::TimeDelta kOneHour = base::TimeDelta::FromHours(1);
+
+// Traits for running RecheckTask. Runs from the UI thread to show notification.
+const base::TaskTraits kRecheckTaskTraits = {
+    content::BrowserThread::UI, base::TaskPriority::BEST_EFFORT,
+    base::TaskShutdownBehavior::SKIP_ON_SHUTDOWN};
+
+// A task to check again if the notification should be shown at a later time.
+// This has weak pointers so that it pending tasks can be canceled.
+class RecheckTask {
+ public:
+  void Run(Profile* profile) {
+    MaybeShowSamlPasswordExpiryNotification(profile);
+  }
+
+  // Check again whether to show notification for |profile| after |delay|.
+  void PostDelayed(Profile* profile, base::TimeDelta delay) {
+    base::PostDelayedTaskWithTraits(
+        FROM_HERE, kRecheckTaskTraits,
+        base::BindOnce(&RecheckTask::Run, weak_ptr_factory.GetWeakPtr(),
+                       profile),
+        delay);
+  }
+
+  // Cancel any scheduled tasks to check again.
+  void CancelIfPending() { weak_ptr_factory.InvalidateWeakPtrs(); }
+
+ private:
+  base::WeakPtrFactory<RecheckTask> weak_ptr_factory{this};
+};
+
+// Keep only a single instance of the RecheckTask, so that we can easily cancel
+// all pending tasks just by invalidating weak pointers to this one task.
+base::NoDestructor<RecheckTask> recheck_task_instance;
+
 }  // namespace
 
+void MaybeShowSamlPasswordExpiryNotification(Profile* profile) {
+  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
+
+  // This cancels any pending RecheckTask - we don't want a situation where
+  // accidentally this function more than once means lots of identical
+  // RecheckTasks are added to the work queue.
+  recheck_task_instance->CancelIfPending();
+
+  // This could be run after a long delay. Check it still makes sense to run.
+  ProfileManager* profile_manager = g_browser_process->profile_manager();
+  if (!profile_manager || !profile_manager->IsValidProfile(profile)) {
+    return;
+  }
+
+  SamlPasswordAttributes attrs =
+      SamlPasswordAttributes::LoadFromPrefs(profile->GetPrefs());
+  if (!attrs.has_expiration_time()) {
+    // No reason to believe the password will ever expire.
+    // Hide the notification (just in case it is shown) and return.
+    DismissSamlPasswordExpiryNotification(profile);
+    return;
+  }
+
+  const base::TimeDelta time_until_expiry =
+      attrs.expiration_time() - base::Time::Now();
+  if (time_until_expiry <= kZeroTimeDelta) {
+    // The password has expired, so we show the notification now.
+    ShowSamlPasswordExpiryNotification(profile, /*less_than_n_days=*/0);
+    return;
+  }
+
+  // The password has not expired, but it will in the future.
+  const int less_than_n_days = time_until_expiry.InDaysFloored() + 1;
+  int advance_warning_days = profile->GetPrefs()->GetInteger(
+      prefs::kSamlPasswordExpirationAdvanceWarningDays);
+  advance_warning_days = std::max(advance_warning_days, 0);
+
+  if (less_than_n_days <= advance_warning_days) {
+    // The password will expire in less than |advance_warning_days|, so we show
+    // a notification now explaining the password will expire soon.
+    ShowSamlPasswordExpiryNotification(profile, less_than_n_days);
+    return;
+  }
+
+  // We have not even reached the advance warning threshold. Run this code again
+  // once we have arrived at expiry_time minus advance_warning_days...
+  base::TimeDelta recheck_delay =
+      time_until_expiry - base::TimeDelta::FromDays(advance_warning_days);
+  // But, wait an extra hour so that when this code is next run, it is clear we
+  // are now inside advance_warning_days (and not right on the boundary).
+  recheck_delay += kOneHour;
+  // And never wait less than an hour before running again - we don't want some
+  // bug causing this code to run every millisecond...
+  recheck_delay = std::max(recheck_delay, kOneHour);
+
+  // Check again whether to show notification after recheck_delay.
+  recheck_task_instance->PostDelayed(profile, recheck_delay);
+}
+
 void ShowSamlPasswordExpiryNotification(Profile* profile, int lessThanNDays) {
+  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
+
   const base::string16 title = GetTitleText(lessThanNDays);
   const base::string16 body = GetBodyText(lessThanNDays);
 
@@ -111,6 +219,7 @@
 }
 
 void DismissSamlPasswordExpiryNotification(Profile* profile) {
+  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
   NotificationDisplayServiceFactory::GetForProfile(profile)->Close(
       kNotificationHandlerType, kNotificationId);
 }
diff --git a/chrome/browser/chromeos/login/saml/saml_password_expiry_notification.h b/chrome/browser/chromeos/login/saml/saml_password_expiry_notification.h
index eeb13c6..0fe152a 100644
--- a/chrome/browser/chromeos/login/saml/saml_password_expiry_notification.h
+++ b/chrome/browser/chromeos/login/saml/saml_password_expiry_notification.h
@@ -11,6 +11,13 @@
 
 // Utility functions to show or hide a password expiry notification.
 
+// Show a password expiry notification if the user's password has expired or
+// soon expires (that is, within pref kSamlPasswordExpirationAdvanceWarningDays
+// time). Otherwise, if the user's password will expire in the more distant
+// future, in that case a notification will be shown in the future. Nothing is
+// shown if the password is not expected to expire.
+void MaybeShowSamlPasswordExpiryNotification(Profile* profile);
+
 // Shows a password expiry notification. |lessThanNDays| should be 1 if the
 // password expires in less than 1 day, 0 if it has already expired, etc.
 // Negative numbers are treated the same as zero.
diff --git a/chrome/browser/chromeos/login/saml/saml_password_expiry_notification_unittest.cc b/chrome/browser/chromeos/login/saml/saml_password_expiry_notification_unittest.cc
index f293fbe..f766c48 100644
--- a/chrome/browser/chromeos/login/saml/saml_password_expiry_notification_unittest.cc
+++ b/chrome/browser/chromeos/login/saml/saml_password_expiry_notification_unittest.cc
@@ -5,9 +5,15 @@
 #include "chrome/browser/chromeos/login/saml/saml_password_expiry_notification.h"
 
 #include "base/strings/utf_string_conversions.h"
+#include "base/test/scoped_task_environment.h"
+#include "chrome/browser/browser_process.h"
 #include "chrome/browser/notifications/notification_display_service_impl.h"
 #include "chrome/browser/notifications/notification_display_service_tester.h"
+#include "chrome/browser/profiles/profile_manager.h"
+#include "chrome/test/base/testing_browser_process.h"
 #include "chrome/test/base/testing_profile.h"
+#include "chrome/test/base/testing_profile_manager.h"
+#include "chromeos/login/auth/saml_password_attributes.h"
 #include "content/public/test/test_browser_thread_bundle.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
@@ -16,11 +22,19 @@
 
 namespace chromeos {
 
+constexpr base::TimeDelta kTimeAdvance = base::TimeDelta::FromMilliseconds(1);
+
 class SamlPasswordExpiryNotificationTest : public testing::Test {
  public:
   void SetUp() override {
+    // Advance time a little bit so that Time::Now().is_null() becomes false.
+    test_environment_.FastForwardBy(kTimeAdvance);
+
+    ASSERT_TRUE(profile_manager_.SetUp());
+    profile_ = profile_manager_.CreateTestingProfile("test");
+
     display_service_tester_ =
-        std::make_unique<NotificationDisplayServiceTester>(&profile_);
+        std::make_unique<NotificationDisplayServiceTester>(profile_);
   }
 
   void TearDown() override { display_service_tester_.reset(); }
@@ -31,14 +45,21 @@
         "saml.password-expiry-notification");
   }
 
-  content::TestBrowserThreadBundle thread_bundle_;
-  TestingProfile profile_;
+  void SetExpirationTime(base::Time expiration_time) {
+    SamlPasswordAttributes attrs(base::Time(), expiration_time, "");
+    attrs.SaveToPrefs(profile_->GetPrefs());
+  }
 
+  content::TestBrowserThreadBundle test_environment_{
+      base::test::ScopedTaskEnvironment::MainThreadType::UI_MOCK_TIME,
+      base::test::ScopedTaskEnvironment::NowSource::MAIN_THREAD_MOCK_TIME};
+  TestingProfileManager profile_manager_{TestingBrowserProcess::GetGlobal()};
+  TestingProfile* profile_;
   std::unique_ptr<NotificationDisplayServiceTester> display_service_tester_;
 };
 
 TEST_F(SamlPasswordExpiryNotificationTest, ShowAlreadyExpired) {
-  ShowSamlPasswordExpiryNotification(&profile_, 0);
+  ShowSamlPasswordExpiryNotification(profile_, 0);
   ASSERT_TRUE(Notification().has_value());
 
   EXPECT_EQ(base::ASCIIToUTF16("Password has expired"),
@@ -47,12 +68,12 @@
                                "Click here to choose a new password"),
             Notification()->message());
 
-  DismissSamlPasswordExpiryNotification(&profile_);
+  DismissSamlPasswordExpiryNotification(profile_);
   EXPECT_FALSE(Notification().has_value());
 }
 
 TEST_F(SamlPasswordExpiryNotificationTest, ShowWillSoonExpire) {
-  ShowSamlPasswordExpiryNotification(&profile_, 14);
+  ShowSamlPasswordExpiryNotification(profile_, 14);
   ASSERT_TRUE(Notification().has_value());
 
   EXPECT_EQ(base::ASCIIToUTF16("Password will soon expire"),
@@ -62,8 +83,84 @@
                 "Click here to choose a new password"),
             Notification()->message());
 
-  DismissSamlPasswordExpiryNotification(&profile_);
+  DismissSamlPasswordExpiryNotification(profile_);
   EXPECT_FALSE(Notification().has_value());
 }
 
+TEST_F(SamlPasswordExpiryNotificationTest, MaybeShow_WillNotExpire) {
+  SamlPasswordAttributes::DeleteFromPrefs(profile_->GetPrefs());
+  MaybeShowSamlPasswordExpiryNotification(profile_);
+
+  EXPECT_FALSE(Notification().has_value());
+  // No notification shown now and nothing shown in the next 10,000 days.
+  test_environment_.FastForwardBy(base::TimeDelta::FromDays(10000));
+  EXPECT_FALSE(Notification().has_value());
+}
+
+TEST_F(SamlPasswordExpiryNotificationTest, MaybeShow_AlreadyExpired) {
+  SetExpirationTime(base::Time::Now() - base::TimeDelta::FromDays(30));
+  MaybeShowSamlPasswordExpiryNotification(profile_);
+
+  // Notification is shown immediately since password has expired.
+  EXPECT_TRUE(Notification().has_value());
+  EXPECT_EQ(base::ASCIIToUTF16("Password has expired"),
+            Notification()->title());
+}
+
+TEST_F(SamlPasswordExpiryNotificationTest, MaybeShow_WillSoonExpire) {
+  SetExpirationTime(base::Time::Now() + base::TimeDelta::FromDays(5));
+  MaybeShowSamlPasswordExpiryNotification(profile_);
+
+  // Notification is shown immediately since password will soon expire.
+  EXPECT_TRUE(Notification().has_value());
+  EXPECT_EQ(base::ASCIIToUTF16("Password will soon expire"),
+            Notification()->title());
+}
+
+TEST_F(SamlPasswordExpiryNotificationTest, MaybeShow_WillEventuallyExpire) {
+  SetExpirationTime(base::Time::Now() + base::TimeDelta::FromDays(95));
+  MaybeShowSamlPasswordExpiryNotification(profile_);
+
+  // Notification is not shown immediately.
+  EXPECT_FALSE(Notification().has_value());
+
+  // But, it will be shown eventually.
+  test_environment_.FastForwardBy(base::TimeDelta::FromDays(90));
+  EXPECT_TRUE(Notification().has_value());
+  EXPECT_EQ(base::ASCIIToUTF16("Password will soon expire"),
+            Notification()->title());
+}
+
+TEST_F(SamlPasswordExpiryNotificationTest, MaybeShow_DeleteExpirationTime) {
+  SetExpirationTime(base::Time::Now() + base::TimeDelta::FromDays(95));
+  MaybeShowSamlPasswordExpiryNotification(profile_);
+
+  // Notification is not shown immediately.
+  EXPECT_FALSE(Notification().has_value());
+
+  // Since expiration time is now removed, it is not shown later either.
+  SamlPasswordAttributes::DeleteFromPrefs(profile_->GetPrefs());
+  test_environment_.FastForwardBy(base::TimeDelta::FromDays(90));
+  EXPECT_FALSE(Notification().has_value());
+}
+
+TEST_F(SamlPasswordExpiryNotificationTest, MaybeShow_Idempotent) {
+  SetExpirationTime(base::Time::Now() + base::TimeDelta::FromDays(95));
+
+  // Calling MaybeShowSamlPasswordExpiryNotification should only add one task -
+  // to maybe show the notification in about 90 days.
+  int baseline_task_count = test_environment_.GetPendingMainThreadTaskCount();
+  MaybeShowSamlPasswordExpiryNotification(profile_);
+  int new_task_count = test_environment_.GetPendingMainThreadTaskCount();
+  EXPECT_EQ(1, new_task_count - baseline_task_count);
+
+  // Calling it many times shouldn't create more tasks - we only need one task
+  // to show the notification in about 90 days.
+  for (int i = 0; i < 10; i++) {
+    MaybeShowSamlPasswordExpiryNotification(profile_);
+  }
+  new_task_count = test_environment_.GetPendingMainThreadTaskCount();
+  EXPECT_EQ(1, new_task_count - baseline_task_count);
+}
+
 }  // namespace chromeos