예제 #1
0
 def test_name_wildcard_input(self):
     generated = list(
         self.query_generator.generate(SearchDirectoryInput(name="foo*"))
     )
     assert len(generated) == 2
     assert generated[0].description == 'Name matches "foo*"'
     assert generated[1].description == 'Name includes "foo*"'
예제 #2
0
    def search_listing(
        request: Request,
        service: DirectorySearchService,
        logger: Logger,
        session: LocalProxy,
        settings: ApplicationConfig,
    ):
        context = RenderingContext.construct(
            uwnetid=session.get("uwnetid"),
            show_experimental=settings.show_experimental,
        )
        try:
            form_input = SearchDirectoryFormInput.parse_obj(request.form)
            context.request_input = form_input

            request_input = SearchDirectoryInput.from_form_input(form_input)
            context.search_result = service.search_directory(request_input)
        except Exception as e:
            logger.exception(str(e))
            SearchBlueprint.handle_search_exception(e, context)
        finally:
            return (
                render_template(
                    "views/search_results.html", **context.dict(exclude_none=True)
                ),
                context.status_code,
            )
예제 #3
0
    def test_phone_input_invalid_number(self):
        request_input = SearchDirectoryInput(phone="abcdefg")
        assert request_input.phone
        assert not request_input.sanitized_phone

        queries = list(self.query_generator.generate(request_input))
        assert not queries
예제 #4
0
    def test_output_includes_department(self, person: str, expected_departments):
        self.session["uwnetid"] = "foo"
        input_population = None
        expected_populations = []

        if "student" in person:
            input_population = PopulationType.students
            expected_populations.append(input_population)

        if "employee" in person:
            if input_population:
                input_population = PopulationType.all
            else:
                input_population = PopulationType.employees
            expected_populations.append(PopulationType.employees)

        input_population = PopulationType(input_population)
        person = getattr(self.mock_people, person)
        self.list_persons_output["Persons"] = [person.dict(by_alias=True)]
        request_input = SearchDirectoryInput(
            name="Lovelace", population=input_population
        )
        output = self.client.search_directory(request_input)
        assert output.scenarios, output.dict()
        for population in expected_populations:
            assert (
                output.scenarios[0].populations[population.value].people[0].departments
                == expected_departments
            )
예제 #5
0
 def test_output_includes_box_number(self):
     person = self.mock_people.published_employee
     self.list_persons_output["Persons"] = [person.dict(by_alias=True)]
     output = self.client.search_directory(
         SearchDirectoryInput(email="*blah", population=PopulationType.employees)
     )
     output_person: Person = output.scenarios[0].populations["employees"].people[0]
     assert output_person.box_number == "351234"
예제 #6
0
    def test_search_directory_happy(self):
        request_input = SearchDirectoryInput(
            name="lovelace", population=PopulationType.employees
        )
        output = self.client.search_directory(request_input)

        assert output.num_results
        assert output.scenarios
        assert output.scenarios[0].populations["employees"].people
예제 #7
0
 def test_department_ignores_invalid_data(self):
     person = self.mock_people.published_employee
     person.affiliations.employee.directory_listing.positions[0].department = None
     self.list_persons_output["Persons"] = [person.dict(by_alias=True)]
     request_input = SearchDirectoryInput(
         name="lovelace", population=PopulationType.employees
     )
     output = self.client.search_directory(request_input)
     output_person: Person = output.scenarios[0].populations["employees"].people[0]
     assert not output_person.departments
 def test_search_methods(self):
     assert SearchDirectoryInput.search_methods() == [
         # Yes, this does mean we need to manually update the test if
         # these fields ever change in any way. This prevents us from
         # unintended changes that may impact the user experience.
         "name",
         "department",
         "email",
         "box_number",
         "phone",
     ]
예제 #9
0
    def test_search_removes_duplicates(self, search_type):
        orig = self.mock_people.as_search_output(self.mock_people.published_employee)
        dupe = orig.copy()
        orig["Next"] = {"Href": "foo"}
        self.set_list_persons_output(orig)
        self.set_get_next_output(ListPersonsOutput.parse_obj(dupe))
        request_input = SearchDirectoryInput(
            name="ada", search_type=search_type, population=PopulationType.all
        )
        output = self.client.search_directory(request_input)

        # But we should only expect a single result because it was de-duplicated
        assert output.num_results == 1
예제 #10
0
    def test_query_student_population(self, authenticate: bool):
        if authenticate:
            self.session["uwnetid"] = "foo"
        request_input = SearchDirectoryInput(
            email="*whatever", population=PopulationType.students
        )

        queries = list(self.query_generator.generate(request_input))
        if authenticate:
            assert len(queries) == 1
            assert queries[0].request_input.employee_affiliation_state is None
            assert queries[0].request_input.student_affiliation_state == "current"
        else:
            assert not queries
예제 #11
0
    def test_query_all_populations(self, authenticate: bool):
        """Ensures that, depending on the population and the authentication context,
        the correct queries are emitted.
        """
        if authenticate:
            self.session["uwnetid"] = "foo"
        request_input = SearchDirectoryInput(
            email="*whatever", population=PopulationType.all
        )
        queries = list(self.query_generator.generate(request_input))

        assert queries[0].request_input.employee_affiliation_state == "current"
        assert queries[0].request_input.student_affiliation_state is None
        if authenticate:
            assert queries[1].request_input.student_affiliation_state == "current"
            assert queries[1].request_input.employee_affiliation_state is None
예제 #12
0
    def test_output_includes_phones(self):
        person = self.mock_people.contactable_person
        self.list_persons_output["Persons"] = [person.dict(by_alias=True)]
        self.session["uwnetid"] = "foo"
        request_input = SearchDirectoryInput(
            name="lovelace", population=PopulationType.all
        )
        output = self.client.search_directory(request_input)
        contacts = output.scenarios[0].populations["employees"].people[0].phone_contacts

        assert contacts == dict(
            phones=["2068675309 Ext. 4242", "19999674222"],
            faxes=["+1 999 214-9864"],
            voice_mails=["1800MYVOICE"],
            touch_dials=["+19999499911"],
            mobiles=["+1 999 (967)-4222", "+1 999 (967) 4999"],
            pagers=["1234567"],
        )
 def test_validate_box_number_valid(self, box_number: str):
     assert SearchDirectoryInput(box_number=box_number).box_number == box_number
 def test_validate_email_success(self, email):
     assert SearchDirectoryInput(email=email).email == email  # email?
예제 #15
0
    def search_directory_experimental(
            self,
            request_input: SearchDirectoryInput) -> SearchDirectoryOutput:
        """
        This new query function improves performance significantly, but is still
        being tested for accuracy and edge cases.

        This only executes one query to PWS per population requested. The
        query includes wildcards for each token the user input.

        For example: "buffy anne summers" would become a query for
        display names matching:
            "*buffy* *summers*"

        In this example, PWS would return any of the following results:
            - buffy anne summers
            - buffy "the vampire slayer" summers
            - ubuffya alsummersia
            - buffy-anne summers
            - buffy anne summers-finn

        After the results have been filtered, they are sent to the
        NameSearchResultReducer, which is responsible for sorting
        these names into appropriate buckets by relevance.
        """
        timer_context = {
            "query":
            request_input.dict(
                exclude_none=True,
                by_alias=True,
                exclude_properties=True,
                exclude_unset=True,
            ),
            "statistics": {},
        }
        timer = Timer("search_directory", context=timer_context).start()
        statistics = ListPersonsRequestStatistics(
            num_queries_generated=1,
            num_user_search_tokens=len(request_input.name.split()),
        )
        query = " ".join(f"*{token}*" for token in request_input.name.split())
        results = {}

        for population in request_input.requested_populations:
            pws_output: ListPersonsOutput = self._pws.list_persons(
                ListPersonsInput(
                    display_name=query,
                    employee_affiliation_state=(AffiliationState.current
                                                if population == "employees"
                                                else None),
                    student_affiliation_state=(AffiliationState.current
                                               if population == "students" else
                                               None),
                ),
                populations=request_input.requested_populations,
            )

            statistics.aggregate(pws_output.request_statistics)
            results = self.reducer.reduce_output(pws_output,
                                                 request_input.name, results)

            while pws_output.next:
                pws_output = self._pws.get_explicit_href(
                    pws_output.next.href, output_type=ListPersonsOutput)
                results = self.reducer.reduce_output(pws_output,
                                                     request_input.name,
                                                     results)
                statistics.aggregate(pws_output.request_statistics)

        statistics.num_duplicates_found = self.reducer.duplicate_hit_count
        timer.context["statistics"] = statistics.dict(by_alias=True)
        timer.stop(emit_log=True)

        return SearchDirectoryOutput(scenarios=[
            DirectoryQueryScenarioOutput(
                description=b.description,
                populations=self.pws_translator.translate_bucket(b),
            ) for b in results.values()
        ])
예제 #16
0
    def search_directory_classic(
            self,
            request_input: SearchDirectoryInput) -> SearchDirectoryOutput:
        timer_context = {
            "query":
            request_input.dict(
                exclude_none=True,
                by_alias=True,
                exclude_properties=True,
                exclude_unset=True,
            ),
            "statistics": {},
        }
        duplicate_netids = set()
        timer = Timer("search_directory", context=timer_context).start()

        statistics = ListPersonsRequestStatistics()
        scenarios: List[DirectoryQueryScenarioOutput] = []
        scenario_description_indexes: Dict[str, int] = {}

        for generated in self.query_generator.generate(request_input):
            self.logger.debug(
                f"Querying: {generated.description} with "
                f"{generated.request_input.dict(exclude_unset=True, exclude_defaults=True)}"
            )
            statistics.num_queries_generated += 1
            pws_output: ListPersonsOutput = self._pws.list_persons(
                generated.request_input,
                populations=request_input.requested_populations)
            aggregate_output = pws_output
            statistics.aggregate(pws_output.request_statistics)

            while pws_output.next and pws_output.next.href:
                pws_output = self._pws.get_explicit_href(pws_output.next.href)
                statistics.aggregate(pws_output.request_statistics)
                aggregate_output.persons.extend(pws_output.persons)

            populations = self.pws_translator.translate_scenario(
                aggregate_output, duplicate_netids)
            statistics.num_duplicates_found += populations.pop(
                "__META__", {}).get("duplicates", 0)

            scenario_output = DirectoryQueryScenarioOutput(
                description=generated.description,
                populations=populations,
            )

            if generated.description in scenario_description_indexes:
                index = scenario_description_indexes[generated.description]
                existing_scenario = scenarios[index]
                for population, results in scenario_output.populations.items():
                    if population not in existing_scenario.populations:
                        existing_scenario.populations[population].people = []
                    existing_scenario.populations[population].people.extend(
                        results.people)
            else:
                scenarios.append(scenario_output)
                scenario_description_indexes[
                    generated.description] = len(scenarios) - 1

        timer.context["statistics"] = statistics.dict(by_alias=True)
        timer.stop(emit_log=True)
        return SearchDirectoryOutput(scenarios=scenarios)
예제 #17
0
 def test_email_input(self, input_value, expected_snippets):
     request_input = SearchDirectoryInput(email=input_value)
     queries = list(self.query_generator.generate(request_input))
     assert len(queries) == len(expected_snippets)
     for i, snippet in enumerate(expected_snippets):
         assert snippet in queries[i].description
예제 #18
0
 def test_box_number_input(self):
     request_input = SearchDirectoryInput(box_number="123456")
     queries = list(self.query_generator.generate(request_input))
     assert len(queries) == 2
     assert queries[0].request_input.mail_stop == "123456"
     assert queries[1].request_input.mail_stop == "35123456"
 def test_validate_email_failure(self, email):
     with pytest.raises(ValidationError):
         SearchDirectoryInput(email="@uw")
예제 #20
0
 def test_phone_input_long_number(self):
     request_input = SearchDirectoryInput(phone="+1 (206) 555-4321")
     queries = list(self.query_generator.generate(request_input))
     assert queries[0].request_input.phone_number == "12065554321"
     assert queries[1].request_input.phone_number == "2065554321"
     assert len(queries) == 2
 def test_validate_box_number_invalid(self, box_number: str):
     with pytest.raises(ValidationError):
         SearchDirectoryInput(box_number=box_number)
 def test_requested_populations(self, input_population, expected):
     assert (
         SearchDirectoryInput(population=input_population).requested_populations
         == expected
     )
예제 #23
0
 def test_email_wildcard_input(self):
     generated = list(
         self.query_generator.generate(SearchDirectoryInput(email="foo*"))
     )
     assert len(generated) == 1
     assert generated[0].description == 'Email matches "foo*"'
예제 #24
0
class AppInjectorModule(Module):
    search_attributes = SearchDirectoryInput.search_methods()

    @provider
    @request
    def provide_request_session(self) -> LocalProxy:
        return cast(
            LocalProxy, flask_session
        )  # Cast this so that IDEs knows what's up; does not affect runtime

    @provider
    @singleton
    def provide_logger(self, yaml_loader: YAMLSettingsLoader,
                       injector: Injector) -> logging.Logger:
        logger_settings = yaml_loader.load_settings("logging")
        dictConfig(logger_settings)
        app_logger = logging.getLogger("gunicorn.error").getChild("app")
        formatter = app_logger.handlers[0].formatter
        formatter.injector = injector
        return app_logger

    def register_jinja_extensions(self, app: Flask):
        """You can define jinja filters here in order to make them available in our jinja templates."""
        @app.template_filter()
        def titleize(text):
            """
            Turns snake_case and camelCase into "Snake Case" and "Camel Case," respectively.
            Use: {{ some_string|titleize }}
            """
            return inflection.titleize(text)

        @app.template_filter()
        def singularize(text: str):
            """
            Takes something plural and makes it singular.
            Use: {{ "parrots"|singularize }}
            """
            return inflection.singularize(text)

        @app.template_filter()
        def linkify(text: str):
            """
            Replaces all non alphanum characters with '-' and lowercases
            everything.
                "foo: bar baz st. claire" => "foo-bar-baz-st-claire"
            use: {{ "Foo Bar Baz St. Claire"|linkify }}
            """
            return inflection.parameterize(text)

        @app.template_filter()
        def externalize(text):
            """
            Some values are great for api models but not so great for humans. So, this allows for that extra layer
            of translation where needed.

            If this gets more complicated for any reason, this table of internal vs. external values should be
            moved into its own service, or at least a dict.
            """
            if text == "employees":
                return "faculty/staff"
            return text

        @app.template_test()
        def blank(val):
            """
            A quick way to test whether a value is undefined OR none.
            This is an alternative to writing '{% if val is defined and val is not sameas None %}'
            """
            return test_undefined(val) or val is None

        @app.context_processor
        def provide_search_attributes():
            """Makes the list of search attributes available to the parser without having to hard-code them."""
            return {"search_attributes": self.search_attributes}

        @app.context_processor
        def provide_current_year():
            return {"current_year": datetime.utcnow().year}

    @provider
    @singleton
    def provide_app(
        self,
        injector: Injector,
        app_settings: ApplicationConfig,
        logger: logging.Logger,
        # Any blueprints that are depended on here must
        # get registered below, under "App blueprints get registered here."
        search_blueprint: SearchBlueprint,
        app_blueprint: AppBlueprint,
        saml_blueprint: SAMLBlueprint,
        mock_saml_blueprint: MockSAMLBlueprint,
    ) -> Flask:
        # First we have to do some logging configuration, before the
        # app instance is created.

        # We've done our pre-work; now we can create the instance itself.
        app = Flask("husky_directory")
        app.jinja_env.trim_blocks = True
        app.jinja_env.lstrip_blocks = True
        app.config.update(app_settings.app_configuration)
        app.url_map.strict_slashes = (
            False  # Allows both '/search' and '/search/' to work
        )

        if app_settings.auth_settings.use_test_idp:
            python3_saml.MOCK = True
            mock.MOCK_LOGIN_URL = "/mock-saml/login"
            app.register_blueprint(mock_saml_blueprint)

        # App blueprints get registered here.
        app.register_blueprint(app_blueprint)
        app.register_blueprint(search_blueprint)
        app.register_blueprint(saml_blueprint)

        # Ensure the application is using the same logger as everything else.
        app.logger = logger

        # Bind an injector to the app itself to manage the scopes of
        # our dependencies appropriate for each request.
        FlaskInjector(app=app, injector=injector)
        self._configure_app_session(app, app_settings)
        self._configure_prometheus(app, app_settings, injector)
        attach_app_error_handlers(app)
        self.register_jinja_extensions(app)
        app.logger.info(
            f"Application worker started at "
            f'{datetime.utcnow().astimezone(pytz.timezone("US/Pacific"))}')
        return app

    @staticmethod
    def _configure_prometheus(app: Flask, app_settings: ApplicationConfig,
                              injector: Injector):
        """
        Sets up a prometheus client with authorization and
        binds it to the Injector using the MetricsClient alias.
        :param app:
        :param app_settings:
        :param injector:
        :return:
        """
        secrets = app_settings.secrets
        metrics_auth = HTTPBasicAuth()

        @metrics_auth.verify_password
        def verify_credentials(username: str, password: str):
            if secrets.prometheus_username and secrets.prometheus_password:
                credentials = (
                    secrets.prometheus_username.get_secret_value(),
                    secrets.prometheus_password.get_secret_value(),
                )
                return (username, password) == credentials
            app.logger.warning(
                "No prometheus authorization is configured. Anyone can scrape these metrics."
            )
            return True  # If the environment isn't configured with auth (e.g., testing)

        metrics = MetricsClient(
            app,
            debug=True,
            metrics_decorator=metrics_auth.login_required,
            defaults_prefix=
            f"{app_settings.metrics_settings.metric_prefix}_flask",
        )
        app.metrics = metrics
        injector.binder.bind(MetricsClient, metrics, scope=singleton)

    @staticmethod
    def _configure_app_session(app: Flask,
                               app_settings: ApplicationConfig) -> NoReturn:
        # There is something wrong with the flask_session implementation that
        # is supposed to translate flask config values into redis settings;
        # also, it doesn't support authorization (what?!) so we have to
        # use their model to explicitly set the interface instead of relying
        # on the magic.
        # TODO: It seems like flask_sessions is actually an abandoned project, so it might
        #       be better to just remove it and implement our own session
        #       interface based on their work. But this is fine for now.
        if app.config["SESSION_TYPE"] == "redis":
            redis_settings = app_settings.redis_settings
            app.logger.info(
                f"Setting up redis cache with settings: {redis_settings.flask_config_values}"
            )
            app.session_interface = RedisSessionInterface(
                redis=Redis(
                    host=redis_settings.host,
                    port=redis_settings.port,
                    username=redis_settings.namespace,
                    password=redis_settings.password.get_secret_value(),
                ),
                key_prefix=redis_settings.
                flask_config_values["SESSION_KEY_PREFIX"],
            )
        else:
            Session(app)
 def test_sanitized_phone(self):
     assert (
         SearchDirectoryInput(phone="+1 (555) 867-5309").sanitized_phone
         == "15558675309"
     )
예제 #26
0
 def test_phone_input_short_number(self):
     request_input = SearchDirectoryInput(phone="2065554321")
     queries = list(self.query_generator.generate(request_input))
     assert len(queries) == 1
class TestSearchBlueprint(BlueprintSearchTestBase):
    def test_render_summary_success(self,
                                    search_method: str = "name",
                                    search_query: str = "lovelace"):
        response = self.flask_client.post("/",
                                          data={
                                              "method": search_method,
                                              "query": search_query
                                          })
        assert response.status_code == 200
        profile = self.mock_people.contactable_person
        with self.html_validator.validate_response(response) as html:
            with self.html_validator.scope("table", summary="results"):
                self.html_validator.assert_has_tag_with_text(
                    "td",
                    profile.affiliations.employee.directory_listing.phones[0],
                )
                self.html_validator.assert_has_tag_with_text(
                    "td",
                    profile.affiliations.employee.directory_listing.emails[0],
                )
            assert "autofocus" not in html.find("input",
                                                attrs={
                                                    "name": "query"
                                                }).attrs

    def _set_up_multipage_search(self):
        page_one = self.mock_people.as_search_output(next_=ListPersonsInput(
            href="https://foo/page-2"))
        page_two = self.mock_send_request.return_value
        self.mock_send_request.return_value = page_one
        mock_next_page = mock.patch.object(self.pws_client,
                                           "get_explicit_href").start()
        mock_next_page.return_value = ListPersonsOutput.parse_obj(page_two)

    def test_render_multi_page_experimental_search(self):
        self._set_up_multipage_search()
        self.test_render_summary_success()

    def test_render_multi_page_classic_search(self):
        self._set_up_multipage_search()
        self.test_render_summary_success(search_method="email",
                                         search_query="foo")

    def test_copyright_footer(self):
        response = self.flask_client.get("/")
        assert response.status_code == 200
        current_year = datetime.utcnow().year
        html: BeautifulSoup
        with self.html_validator.validate_response(response) as html:
            element = html.find("p", attrs={"id": "footer-copyright"})
            assert f"© {current_year}" in element.text

    @pytest.mark.parametrize("log_in", (True, False))
    def test_render_full_success(self, log_in):
        if log_in:
            self.flask_client.get("/saml/login", follow_redirects=True)
            assert self.session.get("uwnetid")

        response = self.flask_client.post(
            "/",
            data={
                "query": "lovelace",
                "method": "name",
                "length": "full",
                "population": "all",
            },
        )

        profile = self.mock_people.contactable_person
        with self.html_validator.validate_response(response):
            self.html_validator.assert_has_tag_with_text(
                "h4", profile.display_name)
            self.html_validator.assert_has_scenario_anchor(
                "employees-last-name-is-lovelace")
            if log_in:
                self.html_validator.assert_has_scenario_anchor(
                    "students-last-name-is-lovelace")
            with self.html_validator.scope("ul", class_="dir-listing"):
                self.html_validator.assert_has_tag_with_text(
                    "li",
                    profile.affiliations.employee.directory_listing.emails[0])
                self.html_validator.assert_has_tag_with_text(
                    "li",
                    profile.affiliations.employee.directory_listing.phones[0])
                self.html_validator.assert_has_tag_with_text(
                    "li", str(profile.affiliations.employee.mail_stop))

    def test_render_student_no_phone(self):
        self.flask_client.get("/saml/login", follow_redirects=True)
        profile = self.mock_people.published_student
        profile.affiliations.student.directory_listing.phone = None
        profile.affiliations.employee = None
        self.mock_send_request.return_value = self.mock_people.as_search_output(
            profile)

        response = self.flask_client.post(
            "/",
            data={
                "query": "lovelace",
                "method": "name",
                "length": "full",
                "population": "all",
            },
        )

        with self.html_validator.validate_response(response):
            assert response.status_code == 200
            self.html_validator.assert_not_has_tag_with_text("li", "Phone:")

    def test_render_no_results(self):
        self.mock_send_request.return_value = self.mock_people.as_search_output(
        )
        response = self.flask_client.post("/", data={"query": "lovelace"})
        with self.html_validator.validate_response(response) as html:
            self.html_validator.assert_not_has_scenario_anchor(
                "employees-name-matches-lovelace")
            self.html_validator.assert_not_has_scenario_anchor(
                "students-name-matches-lovelace")
            assert not html.find("table", summary="results")
            assert html.find(string=re.compile("No matches for"))
            self.html_validator.assert_has_tag_with_text(
                "b", 'Name is "lovelace"')

    def test_render_invalid_box_number(self):
        response = self.flask_client.post("/",
                                          data={
                                              "query": "abcdef",
                                              "method": "box_number"
                                          })
        assert response.status_code == 400
        with self.html_validator.validate_response(response) as html:
            assert not html.find("table", summary="results")
            assert html.find(string=re.compile("Encountered error"))
            self.html_validator.assert_has_tag_with_text(
                "b", "box number (input can only contain digits)")

    def test_render_full_no_box_number(self):
        self.mock_send_request.return_value = self.mock_people.as_search_output(
            self.mock_people.published_student)
        self.flask_client.get("/saml/login", follow_redirects=True)
        with self.html_validator.validate_response(
                self.flask_client.post(
                    "/",
                    data={
                        "query": "lovelace",
                        "length": "full",
                        "population": "all",
                    },
                )) as html:
            assert not html.find_all("li", class_="dir-boxstuff")
            self.html_validator.assert_has_scenario_anchor(
                "students-last-name-is-lovelace")
            self.html_validator.assert_not_has_scenario_anchor(
                "employees-last-name-is-lovelace")
            with self.html_validator.scope("div", class_="usebar"):
                self.html_validator.assert_has_submit_button_with_text(
                    "Download vcard")

    def test_render_unexpected_error(self):
        self.mock_send_request.side_effect = RuntimeError
        response = self.flask_client.post("/",
                                          data={
                                              "query": "123456",
                                              "method": "box_number"
                                          })
        with self.html_validator.validate_response(response):
            self.html_validator.assert_has_tag_with_text(
                "b", "Something unexpected happened")

    def test_user_login_flow(self):
        with self.html_validator.validate_response(self.flask_client.get("/")):
            self.html_validator.assert_not_has_student_search_options()
            self.html_validator.assert_has_sign_in_link()

        with self.html_validator.validate_response(
                self.flask_client.get("/saml/login", follow_redirects=True)):
            self.html_validator.assert_has_student_search_options()
            self.html_validator.assert_not_has_sign_in_link()

    def test_user_stays_logged_in_after_search(self):
        self.test_user_login_flow()
        with self.html_validator.validate_response(
                self.flask_client.post(
                    "/",
                    data={
                        "query": "lovelace",
                        "method": "name",
                    },
                )):
            self.html_validator.assert_has_student_search_options()
            self.html_validator.assert_not_has_sign_in_link()

    def test_user_stays_logged_in_revisit(self):
        self.test_user_login_flow()
        with self.html_validator.validate_response(self.flask_client.get("/")):
            self.html_validator.assert_has_student_search_options()
            self.html_validator.assert_not_has_sign_in_link()

    @pytest.mark.parametrize(
        "search_field, search_value",
        [
            ("name", "bugbear"),
            ("phone", "abcdefg"),
            ("department", "UW-IT IAM"),
            ("box_number", "12345"),
            ("email", "*****@*****.**"),
        ],
    )
    def test_render_no_matches(self, search_field, search_value):
        query_output = self.mock_people.as_search_output()
        self.mock_send_request.return_value = query_output
        with self.html_validator.validate_response(
                self.flask_client.post("/",
                                       data={
                                           "method": search_field,
                                           "query": search_value
                                       })):
            self.html_validator.assert_has_tag_with_text(
                "p",
                f'no matches for {titleize(search_field)} is "{search_value}"')

    def assert_form_fields_match_expected(
        self,
        response,
        expected: SearchDirectoryFormInput,
        signed_in: bool,
        recurse: bool = True,
    ):
        """
        Given a query response, ensures that the user flow thereafter is as expected; if
        the query resulted in summary results with a "more" button, this also simulates
        the button request to ensure that the same state is preserved through
        the next search request.
        """
        with self.html_validator.validate_response(response) as html:
            # Someone not signed in who posts a different population will not
            # have the population options displayed that they selected,
            # so we skip this check. (No actual user that is using
            # our website normally will encounter this situation,
            # but it's technically a possibility.)
            if signed_in or expected.population == "employees":
                assert ("checked" in html.find(
                    "input",
                    attrs={
                        "id": f"population-option-{expected.population}"
                    },
                ).attrs)

            # Ensure that the sign in link is (or is not) visible, based on whether the user
            # is signed in.
            self.html_validator.has_sign_in_link(
                assert_=True, assert_expected_=not signed_in)

            # Ensure that the form field is filled in with the same information as was input.
            assert (html.find("input", attrs={
                "name": "query"
            }).attrs.get("value") == expected.query)

            # Ensure that the result detail option is preserved
            assert ("checked" in html.find("input",
                                           attrs={
                                               "id":
                                               f"length-{expected.length}"
                                           }).attrs)

            # Ensure that the search field is selected in the form dropdown
            assert ("selected" in html.find("option",
                                            attrs={
                                                "value": expected.method
                                            }).attrs)

            # We don't always expect results. For our current test ecosystem,
            # we won't see results if:
            expect_results = signed_in or expected.population != "students"

            # If we don't expect results, we do expect a message telling us
            # that there are no results.
            if not expect_results:
                self.html_validator.assert_has_tag_with_text(
                    "p",
                    f'No matches for {titleize(expected.method)} is "{expected.query}"',
                )
            # If we have "More" buttons, we simulate clicking on them to ensure that
            # the buttons properly set render_ options.
            elif recurse and expected.length == "summary":
                # Ensure that the same values are carried into the "More" render
                more_button = html.find("form", id="more-form-1")
                assert more_button, str(html)
                request_input = self._get_request_input_from_more_button(
                    more_button)
                self.assert_form_fields_match_expected(
                    self.flask_client.post("/", data=request_input.dict()),
                    expected,
                    signed_in=signed_in,
                    recurse=False,
                )

    @staticmethod
    def _get_request_input_from_more_button(
        button: BeautifulSoup, ) -> SearchDirectoryFormInput:
        """This iterates through the hidden input elements that make up our
        "more form", which is a form masquerading as a button for the time being.
        The element values are serialized into the same request input that
        clicking on the button would generate, so that we can validate the
        correct options were set, and that the server renders those
        overrides correctly.
        """
        def get_field_value(field):
            return button.find("input",
                               attrs=dict(name=field)).attrs.get("value")

        return SearchDirectoryFormInput(
            population=get_field_value("population"),
            query=get_field_value("query"),
            method=get_field_value("method"),
            length=get_field_value("length"),
            render_query=get_field_value("render_query"),
            render_method=get_field_value("render_method"),
            render_length=get_field_value("render_length"),
        )

    @pytest.mark.parametrize("search_field",
                             SearchDirectoryInput.search_methods())
    @pytest.mark.parametrize("population", ("employees", "students", "all"))
    @pytest.mark.parametrize("sign_in", (True, False))
    @pytest.mark.parametrize("result_detail", ("full", "summary"))
    def test_render_form_option_stickiness(self, search_field, population,
                                           sign_in, result_detail):
        """
        This uses combinatoric parametrize calls to run through every combination of
        options and ensure that, after rendering, everything is rendered as we expect
        based on the search parameters.

        This generates tests for every combination of the parametrized fields listed above,
        so that we can have a great deal of confidence that the page is rendering as expected
        based on its input.
        """
        query_value = ("lovelace" if search_field
                       not in ("phone", "box_number") else "12345")

        request = SearchDirectoryFormInput(
            method=search_field,
            population=PopulationType(population),
            length=ResultDetail(result_detail),
            query=query_value,
        )

        if sign_in:
            with self.html_validator.validate_response(
                    self.flask_client.get("/saml/login",
                                          follow_redirects=True)) as html:
                self.html_validator.assert_not_has_sign_in_link()
                assert html.find("label",
                                 attrs={"for": "population-option-students"})

        response = self.flask_client.post("/", data=request.dict())
        self.assert_form_fields_match_expected(response,
                                               request,
                                               sign_in,
                                               recurse=True)

    def test_get_person_method_not_allowed(self):
        response = self.flask_client.get("/person/listing")
        assert response.status_code == 405

    @pytest.mark.parametrize("search_term", ["Smith", "Lovelace"])
    def test_get_person_non_matching_surname(self, search_term):
        """Ensures that our name constraints work by setting
        a preferred last name that is different than the registered
        last name, and searching for the registered last name.
        """
        profile = self.mock_people.published_employee
        profile.preferred_last_name = "Smith"
        profile.display_name = "Ada Smith"

        expected_num_results = 1 if profile.preferred_last_name == search_term else 0

        self.mock_send_request.return_value = self.mock_people.as_search_output(
            profile)

        response = self.flask_client.post("/",
                                          data={
                                              "method": "name",
                                              "query": search_term
                                          })
        assert response.status_code == 200
        with self.html_validator.validate_response(response) as html:
            assert (len(html.find_all(
                "tr",
                class_="summary-row")) == expected_num_results), str(html)

    def test_list_people_sort(self, random_string):
        ada_1 = self.mock_people.published_employee.copy(
            update={
                "display_name": "Ada Zlovelace",
                "preferred_last_name": "Zlovelace",
                "registered_surname": "Alovelace",
                "netid": random_string(),
            },
            deep=True,
        )
        ada_1.affiliations.employee.directory_listing.phones = ["222-2222"]
        ada_2 = self.mock_people.published_employee.copy(
            update={
                "display_name": "Ada Blovelace",
                "registered_surname": "Blovelace",
                "netid": random_string(),
            },
            deep=True,
        )
        ada_2.affiliations.employee.directory_listing.phones = ["888-8888"]
        ada_3 = self.mock_people.published_employee.copy(
            update={
                "display_name": "Ada Alovelace",
                "preferred_last_name": "Alovelace",
                "netid": random_string(),
            },
            deep=True,
        )
        ada_3.affiliations.employee.directory_listing.phones = ["999-9999"]
        people = self.mock_people.as_search_output(ada_1, ada_2, ada_3)

        self.mock_send_request.return_value = people
        response = self.flask_client.post(
            "/",
            data={
                "method": "name",
                "query": "lovelace"
            },
        )
        html = response.data.decode("UTF-8")
        assert response.status_code == 200
        assert html.index("999-9999") < html.index("888-8888")
        assert html.index("888-8888") < html.index("222-2222")

    def test_get_person_vcard(self):
        """
        Tests that the blueprint returns the right result, but does not test
        permutations of vcards; for that see, tests/services/vcard.py
        """
        person = self.mock_people.published_employee
        href = base64.b64encode("foo".encode("UTF-8")).decode("UTF-8")
        self.mock_send_request.return_value = person.dict(by_alias=True)
        response = self.flask_client.post("/person/vcard",
                                          data={"person_href": href})
        assert response.status_code == 200
        assert response.mimetype == "text/vcard"
        vcard = response.data.decode("UTF-8")
        assert vcard.startswith("BEGIN:VCARD")
        assert vcard.endswith("END:VCARD")

    @pytest.mark.parametrize("succeed", (True, False))
    def test_get_person_listing(self, succeed: bool):
        person = self.mock_people.contactable_person
        href = base64.b64encode("foo".encode("UTF-8")).decode("UTF-8")
        if succeed:
            self.mock_send_request.return_value = person.dict(by_alias=True)
        else:
            self.mock_send_request.side_effect = exceptions.NotFound
        response = self.flask_client.post("/person/listing",
                                          data={"person_href": href})
        if succeed:
            assert response.status_code == 200
            with self.html_validator.validate_response(response):
                self.html_validator.assert_has_tag_with_text(
                    "h4", "Ada Lovelace")
        else:
            assert response.status_code == 404
            with self.html_validator.validate_response(response):
                self.html_validator.assert_has_tag_with_text(
                    "p", "404 not found")