diff --git a/schemes/__init__.py b/schemes/__init__.py index 038720c5..27e67e31 100644 --- a/schemes/__init__.py +++ b/schemes/__init__.py @@ -78,8 +78,6 @@ def bindings(binder: Binder) -> None: csrf.exempt(authorities.add_schemes) csrf.exempt(authorities.clear) app.register_blueprint(schemes.bp, url_prefix="/schemes") - # TODO: add CSRF to scheme review form - csrf.exempt(schemes.schemes.review) csrf.exempt(schemes.schemes.clear) app.register_blueprint(users.bp, url_prefix="/users") csrf.exempt(users.clear) diff --git a/schemes/views/schemes/schemes.py b/schemes/views/schemes/schemes.py index 94c3d11d..78453abe 100644 --- a/schemes/views/schemes/schemes.py +++ b/schemes/views/schemes/schemes.py @@ -17,6 +17,7 @@ session, url_for, ) +from flask_wtf import FlaskForm from werkzeug import Response as BaseResponse from schemes.dicts import as_shallow_dict, inverse_dict @@ -166,6 +167,7 @@ class SchemeContext: funding: SchemeFundingContext milestones: SchemeMilestonesContext outputs: SchemeOutputsContext + review: SchemeReviewContext @classmethod def from_domain( @@ -180,6 +182,7 @@ def from_domain( funding=SchemeFundingContext.from_domain(scheme.funding), milestones=SchemeMilestonesContext.from_domain(scheme.milestones), outputs=SchemeOutputsContext.from_domain(scheme.outputs.current_output_revisions), + review=SchemeReviewContext(), ) @@ -234,6 +237,15 @@ def from_domain(cls, funding_programme: FundingProgramme | None) -> FundingProgr return cls(name=cls._NAMES[funding_programme] if funding_programme else None) +class SchemeReviewForm(FlaskForm): # type: ignore + pass + + +@dataclass(frozen=True) +class SchemeReviewContext: + form: SchemeReviewForm = field(default_factory=SchemeReviewForm) + + @bp.get("/spend-to-date") @bearer_auth @inject.autoparams("users", "schemes") @@ -318,7 +330,7 @@ def milestones(users: UserRepository, clock: Clock, schemes: SchemeRepository, s return redirect(url_for("schemes.get", scheme_id=scheme_id)) -@bp.post("/review") +@bp.post("") @bearer_auth @inject.autoparams("clock", "schemes") def review(clock: Clock, schemes: SchemeRepository, scheme_id: int) -> BaseResponse: diff --git a/schemes/views/templates/scheme/_review.html b/schemes/views/templates/scheme/_review.html index 94146ee4..366ee642 100644 --- a/schemes/views/templates/scheme/_review.html +++ b/schemes/views/templates/scheme/_review.html @@ -2,6 +2,8 @@ {% from "govuk_frontend_jinja/components/checkboxes/macro.html" import govukCheckboxes %}
+ {{ review.form.csrf_token }} + {{ govukCheckboxes({ "name": "review", "fieldset": { diff --git a/schemes/views/templates/scheme/index.html b/schemes/views/templates/scheme/index.html index 04c6a0e6..29f8a90e 100644 --- a/schemes/views/templates/scheme/index.html +++ b/schemes/views/templates/scheme/index.html @@ -1,5 +1,6 @@ {% extends "service_base.html" %} {% from "govuk_frontend_jinja/components/back-link/macro.html" import govukBackLink %} +{% from "govuk_frontend_jinja/components/notification-banner/macro.html" import govukNotificationBanner %} {% from "govuk_frontend_jinja/components/tag/macro.html" import govukTag %} {% block beforeContent %} @@ -10,6 +11,14 @@ {% endblock %} {% block content %} + {% with messages = get_flashed_messages() %} + {% if messages %} + {{ govukNotificationBanner(params={ + "text": messages | first + }) }} + {% endif %} + {% endwith %} +

{{ authority_name }} {{ name }} diff --git a/tests/integration/pages.py b/tests/integration/pages.py index 775541bc..ab120155 100644 --- a/tests/integration/pages.py +++ b/tests/integration/pages.py @@ -115,6 +115,10 @@ class SchemePage(PageObject): def __init__(self, response: TestResponse): super().__init__(response) self.back_url = one(self._soup.select("a.govuk-back-link"))["href"] + notification_banner_tag = self._soup.select_one(".govuk-notification-banner") + self.notification_banner = ( + NotificationBannerComponent(notification_banner_tag) if notification_banner_tag else None + ) self.authority = one(self._soup.select("main h1 .govuk-caption-xl")).string self.name = one(self._soup.select("main h1 span:nth-child(2)")).string tag = self._soup.select_one("main h1 .govuk-tag") diff --git a/tests/integration/test_scheme_review.py b/tests/integration/test_scheme_review.py index c8e5efd5..8ed8de76 100644 --- a/tests/integration/test_scheme_review.py +++ b/tests/integration/test_scheme_review.py @@ -23,9 +23,11 @@ def test_scheme_shows_confirm(self, schemes: SchemeRepository, client: FlaskClie scheme_page = SchemePage.open(client, id_=1) - assert scheme_page.review.confirm_url == "/schemes/1/review" + assert scheme_page.review.confirm_url == "/schemes/1" - def test_review_updates_last_reviewed(self, clock: Clock, schemes: SchemeRepository, client: FlaskClient) -> None: + def test_review_updates_last_reviewed( + self, clock: Clock, schemes: SchemeRepository, client: FlaskClient, csrf_token: str + ) -> None: clock.now = datetime(2023, 4, 24, 12) scheme = Scheme(id_=1, name="Wirral Package", authority_id=1) scheme.update_authority_review( @@ -33,7 +35,7 @@ def test_review_updates_last_reviewed(self, clock: Clock, schemes: SchemeReposit ) schemes.add(scheme) - client.post("/schemes/1/review", data={}) + client.post("/schemes/1", data={"csrf_token": csrf_token}) actual_scheme = schemes.get(1) assert actual_scheme @@ -45,9 +47,31 @@ def test_review_updates_last_reviewed(self, clock: Clock, schemes: SchemeReposit and authority_review2.source == DataSource.AUTHORITY_UPDATE ) - def test_review_shows_schemes(self, schemes: SchemeRepository, client: FlaskClient) -> None: + def test_review_shows_schemes(self, schemes: SchemeRepository, client: FlaskClient, csrf_token: str) -> None: schemes.add(Scheme(id_=1, name="Wirral Package", authority_id=1)) - response = client.post("/schemes/1/review", data={}) + response = client.post("/schemes/1", data={"csrf_token": csrf_token}) assert response.status_code == 302 and response.location == "/schemes" + + def test_cannot_review_when_no_csrf_token(self, schemes: SchemeRepository, client: FlaskClient) -> None: + schemes.add(Scheme(id_=1, name="Wirral Package", authority_id=1)) + + scheme_page = SchemePage(client.post("/schemes/1", data={}, follow_redirects=True)) + + assert scheme_page.name == "Wirral Package" + assert ( + scheme_page.notification_banner + and scheme_page.notification_banner.heading == "The form you were submitting has expired. Please try again." + ) + + def test_cannot_review_when_incorrect_csrf_token(self, schemes: SchemeRepository, client: FlaskClient) -> None: + schemes.add(Scheme(id_=1, name="Wirral Package", authority_id=1)) + + scheme_page = SchemePage(client.post("/schemes/1", data={"csrf_token": "x"}, follow_redirects=True)) + + assert scheme_page.name == "Wirral Package" + assert ( + scheme_page.notification_banner + and scheme_page.notification_banner.heading == "The form you were submitting has expired. Please try again." + ) diff --git a/tests/views/schemes/test_schemes.py b/tests/views/schemes/test_schemes.py index 336bb225..a520a287 100644 --- a/tests/views/schemes/test_schemes.py +++ b/tests/views/schemes/test_schemes.py @@ -2,6 +2,7 @@ from decimal import Decimal import pytest +from flask_wtf import FlaskForm from schemes.domain.authorities import Authority from schemes.domain.dates import DateRange @@ -43,6 +44,8 @@ FundingProgrammeRepr, SchemeContext, SchemeOverviewContext, + SchemeReviewContext, + SchemeReviewForm, SchemeRowContext, SchemesContext, SchemeTypeContext, @@ -131,6 +134,7 @@ def test_from_domain_sets_last_reviewed(self) -> None: assert context.last_reviewed == datetime(2020, 1, 3, 12) +@pytest.mark.usefixtures("app") class TestSchemeContext: def test_from_domain(self) -> None: authority = Authority(id_=2, name="Liverpool City Region Combined Authority") @@ -144,6 +148,7 @@ def test_from_domain(self) -> None: and context.name == "Wirral Package" and not context.needs_review and context.overview.reference == "ATE00001" + and isinstance(context.review.form, SchemeReviewForm) ) @pytest.mark.parametrize( @@ -345,6 +350,22 @@ def test_from_domain(self, funding_programme: FundingProgramme | None, expected_ assert context == FundingProgrammeContext(name=expected_name) +@pytest.mark.usefixtures("app") +class TestSchemeReviewContext: + def test_create(self) -> None: + context = SchemeReviewContext() + + assert isinstance(context.form, SchemeReviewForm) + + +@pytest.mark.usefixtures("app") +class TestSchemeReviewForm: + def test_create(self) -> None: + form = SchemeReviewForm() + + assert isinstance(form, FlaskForm) + + class TestSchemeRepr: def test_from_domain(self) -> None: scheme = Scheme(id_=1, name="Wirral Package", authority_id=2)