diff --git a/.github/actions/run-accessibility-tests/action.yml b/.github/actions/run-accessibility-tests/action.yml index c5d0feea..d35e2133 100644 --- a/.github/actions/run-accessibility-tests/action.yml +++ b/.github/actions/run-accessibility-tests/action.yml @@ -5,6 +5,9 @@ inputs: url: required: true type: string + auth_secret: + required: true + type: string runs: using: composite @@ -12,7 +15,7 @@ runs: steps: - name: Run Pa11y shell: bash - run: npm install -g pa11y-ci && pa11y-ci ${{ inputs.url }} --config .pa11yci-ubuntu.json + run: npm install -g pa11y-ci && export AUTH_SECRET=${{ inputs.auth_secret }} && pa11y-ci ${{ inputs.url }} --config .pa11yci-ubuntu.js working-directory: ./tests/Dfe.EarlyYearsQualification.AccessibilityTests/ \ No newline at end of file diff --git a/.github/actions/run-app-for-testing/action.yml b/.github/actions/run-app-for-testing/action.yml index f88e5352..a19f542d 100644 --- a/.github/actions/run-app-for-testing/action.yml +++ b/.github/actions/run-app-for-testing/action.yml @@ -3,8 +3,14 @@ description: Run the app within the action so we can run E2E and Accessibility t inputs: url: + description: "URL used by the app to listen" required: true type: string + auth_secret: + description: "Auth secret used by end-to-end tests" + required: true + type: string + runs: using: composite @@ -12,4 +18,7 @@ runs: steps: - name: Run .Net Project shell: bash - run: dotnet run --urls "${{ inputs.url }}" --project="src/Dfe.EarlyYearsQualification.Web" --UseMockContentful=true & + run: | + dotnet run --urls "${{ inputs.url }}" --project="src/Dfe.EarlyYearsQualification.Web" \ + --UseMockContentful=true \ + --ServiceAccess:Keys:0="${{ inputs.auth_secret }}" & diff --git a/.github/actions/run-e2e-tests/action.yml b/.github/actions/run-e2e-tests/action.yml index fa165d07..cd3d36ba 100644 --- a/.github/actions/run-e2e-tests/action.yml +++ b/.github/actions/run-e2e-tests/action.yml @@ -5,6 +5,9 @@ inputs: url: required: true type: string + auth_secret: + required: true + type: string runs: using: composite @@ -14,6 +17,7 @@ runs: uses: cypress-io/github-action@v6 if: success() || failure() with: + env: auth_secret="${{ inputs.auth_secret }}" working-directory: ./tests/Dfe.EarlyYearsQualification.E2ETests/ browser: chrome config: baseUrl="${{ inputs.url }}" @@ -22,12 +26,13 @@ runs: uses: cypress-io/github-action@v6 if: success() || failure() with: + env: auth_secret="${{ inputs.auth_secret }}" working-directory: ./tests/Dfe.EarlyYearsQualification.E2ETests/ browser: firefox config: baseUrl="${{ inputs.url }}" - name: Store screenshots on test failure - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: cypress-screenshots diff --git a/.github/workflows/code-pr-check.yml b/.github/workflows/code-pr-check.yml index dfe50f74..d628f0d6 100644 --- a/.github/workflows/code-pr-check.yml +++ b/.github/workflows/code-pr-check.yml @@ -54,13 +54,16 @@ jobs: uses: ./.github/actions/run-app-for-testing with: url: ${{ env.URL }} + auth_secret: ${{ secrets.WEBAPP_E2E_ACCESS_KEY }} - name: Run e2e tests uses: ./.github/actions/run-e2e-tests with: url: ${{ env.URL }} + auth_secret: ${{ secrets.WEBAPP_E2E_ACCESS_KEY }} - name: Run Accessibility tests uses: ./.github/actions/run-accessibility-tests with: url: ${{ env.URL }} + auth_secret: ${{ secrets.WEBAPP_E2E_ACCESS_KEY }} diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/AccessibilityStatementController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/AccessibilityStatementController.cs index e27a62ef..80013dc3 100644 --- a/src/Dfe.EarlyYearsQualification.Web/Controllers/AccessibilityStatementController.cs +++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/AccessibilityStatementController.cs @@ -1,6 +1,7 @@ using Dfe.EarlyYearsQualification.Content.Entities; using Dfe.EarlyYearsQualification.Content.Renderers.Entities; using Dfe.EarlyYearsQualification.Content.Services; +using Dfe.EarlyYearsQualification.Web.Controllers.Base; using Dfe.EarlyYearsQualification.Web.Models.Content; using Microsoft.AspNetCore.Mvc; @@ -11,7 +12,7 @@ public class AccessibilityStatementController( ILogger logger, IContentService contentService, IHtmlRenderer renderer) - : Controller + : ServiceController { [HttpGet] public async Task Index() @@ -21,7 +22,7 @@ public async Task Index() if (content is null) { logger.LogError("No content for the accessibility statement page"); - return RedirectToAction("Error", "Home"); + return RedirectToAction("Index", "Error"); } var model = await Map(content); diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/AdviceController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/AdviceController.cs index c58ad539..5671af63 100644 --- a/src/Dfe.EarlyYearsQualification.Web/Controllers/AdviceController.cs +++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/AdviceController.cs @@ -2,6 +2,7 @@ using Dfe.EarlyYearsQualification.Content.Entities; using Dfe.EarlyYearsQualification.Content.Renderers.Entities; using Dfe.EarlyYearsQualification.Content.Services; +using Dfe.EarlyYearsQualification.Web.Controllers.Base; using Dfe.EarlyYearsQualification.Web.Models.Content; using Microsoft.AspNetCore.Mvc; @@ -9,7 +10,7 @@ namespace Dfe.EarlyYearsQualification.Web.Controllers; [Route("/advice")] public class AdviceController(ILogger logger, IContentService contentService, IHtmlRenderer renderer) - : Controller + : ServiceController { [HttpGet("qualification-outside-the-united-kingdom")] public async Task QualificationOutsideTheUnitedKingdom() @@ -22,8 +23,8 @@ private async Task GetView(string advicePageId) var advicePage = await contentService.GetAdvicePage(advicePageId); if (advicePage is null) { - logger.LogError("No content for the advice page"); - return RedirectToAction("Error", "Home"); + logger.LogError("No content for the advice page"); + return RedirectToAction("Index", "Error"); } var model = await Map(advicePage); diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/Base/ServiceController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/Base/ServiceController.cs new file mode 100644 index 00000000..d2c5c0d2 --- /dev/null +++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/Base/ServiceController.cs @@ -0,0 +1,14 @@ +using Dfe.EarlyYearsQualification.Web.Filters; +using Microsoft.AspNetCore.Mvc; + +namespace Dfe.EarlyYearsQualification.Web.Controllers.Base; + +/// +/// Controller class that is guarded by a +/// so that it is possible, while in private beta and in non-production environments, +/// to configure a secret that must be entered to gain access to the service. +/// All controllers except , +/// and should derive from this type. +/// +[ServiceFilter] +public class ServiceController : Controller; \ No newline at end of file diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/ChallengeController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/ChallengeController.cs new file mode 100644 index 00000000..401b4aa3 --- /dev/null +++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/ChallengeController.cs @@ -0,0 +1,69 @@ +using Dfe.EarlyYearsQualification.Web.Filters; +using Dfe.EarlyYearsQualification.Web.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Dfe.EarlyYearsQualification.Web.Controllers; + +public class ChallengeController( + ILogger logger, + IUrlHelper urlHelper) + : Controller +{ + private const string DefaultRedirectAddress = "/"; + + [HttpGet] + [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] + public Task Index([FromQuery] ChallengeModel model) + { + if (!ModelState.IsValid) + { + logger.LogWarning("Invalid challenge model (get)"); + } + + model.RedirectAddress = SanitiseReferralAddress(model.RedirectAddress); + + logger.LogWarning("Challenge page invoked"); + + return Task.FromResult(View("EntryForm", model)); + } + + [HttpPost] + public Task Post([FromForm] ChallengeModel model) + { + if (!ModelState.IsValid) + { + logger.LogWarning("Invalid challenge model (post)"); + } + + var referralAddress = SanitiseReferralAddress(model.RedirectAddress); + + if (model.Value != null) + { + logger.LogInformation("Challenge secret access value entered successfully"); + + SetAuthSecretCookie(model.Value); + return Task.FromResult(new RedirectResult(referralAddress)); + } + + return Index(model); + } + + private void SetAuthSecretCookie(string accessValue) + { + HttpContext.Response + .Cookies + .Append(ChallengeResourceFilterAttribute.AuthSecretCookieName, accessValue); + } + + private string SanitiseReferralAddress(string? from) + { + var redirectAddress = from ?? DefaultRedirectAddress; + + if (!urlHelper.IsLocalUrl(redirectAddress)) + { + redirectAddress = DefaultRedirectAddress; + } + + return redirectAddress; + } +} \ No newline at end of file diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/CookiesController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/CookiesController.cs index 7e930317..522e91aa 100644 --- a/src/Dfe.EarlyYearsQualification.Web/Controllers/CookiesController.cs +++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/CookiesController.cs @@ -1,6 +1,7 @@ using Dfe.EarlyYearsQualification.Content.Entities; using Dfe.EarlyYearsQualification.Content.Renderers.Entities; using Dfe.EarlyYearsQualification.Content.Services; +using Dfe.EarlyYearsQualification.Web.Controllers.Base; using Dfe.EarlyYearsQualification.Web.Models.Content; using Dfe.EarlyYearsQualification.Web.Services.CookieService; using Microsoft.AspNetCore.Mvc; @@ -15,7 +16,7 @@ public class CookiesController( ISuccessBannerRenderer successBannerRenderer, ICookieService cookieService, IUrlHelper urlHelper) - : Controller + : ServiceController { [HttpGet] public async Task Index() @@ -25,7 +26,7 @@ public async Task Index() if (content is null) { logger.LogError("No content for the cookies page"); - return RedirectToAction("Error", "Home"); + return RedirectToAction("Index", "Error"); } var model = await Map(content); @@ -34,21 +35,21 @@ public async Task Index() } [HttpPost("accept")] - public IActionResult Accept([FromForm]string? returnUrl) + public IActionResult Accept([FromForm] string? returnUrl) { cookieService.SetPreference(true); return Redirect(CheckUrl(returnUrl)); } [HttpPost("reject")] - public IActionResult Reject([FromForm]string? returnUrl) + public IActionResult Reject([FromForm] string? returnUrl) { cookieService.RejectCookies(); return Redirect(CheckUrl(returnUrl)); } [HttpPost("hidebanner")] - public IActionResult HideBanner([FromForm]string? returnUrl) + public IActionResult HideBanner([FromForm] string? returnUrl) { cookieService.SetVisibility(false); return Redirect(CheckUrl(returnUrl)); @@ -65,13 +66,14 @@ public IActionResult CookiePreference(string cookiesAnswer) { cookieService.RejectCookies(); } + TempData["UserPreferenceRecorded"] = true; return Redirect("/cookies"); } private string CheckUrl(string? url) { - return urlHelper.IsLocalUrl(url) ? url : "/cookies"; + return urlHelper.IsLocalUrl(url) ? url : "/cookies"; } private async Task Map(CookiesPage content) diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/ErrorController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/ErrorController.cs new file mode 100644 index 00000000..03bf3d42 --- /dev/null +++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/ErrorController.cs @@ -0,0 +1,16 @@ +using System.Diagnostics; +using Dfe.EarlyYearsQualification.Web.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Dfe.EarlyYearsQualification.Web.Controllers; + +[Route("/error")] +public class ErrorController : Controller +{ + [HttpGet] + [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] + public IActionResult Index() + { + return View("Error", new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); + } +} \ No newline at end of file diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/HomeController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/HomeController.cs index 00365434..9f08ec12 100644 --- a/src/Dfe.EarlyYearsQualification.Web/Controllers/HomeController.cs +++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/HomeController.cs @@ -1,8 +1,7 @@ -using System.Diagnostics; using Dfe.EarlyYearsQualification.Content.Entities; using Dfe.EarlyYearsQualification.Content.Renderers.Entities; using Dfe.EarlyYearsQualification.Content.Services; -using Dfe.EarlyYearsQualification.Web.Models; +using Dfe.EarlyYearsQualification.Web.Controllers.Base; using Dfe.EarlyYearsQualification.Web.Models.Content; using Microsoft.AspNetCore.Mvc; @@ -13,7 +12,7 @@ public class HomeController( IContentService contentService, IHtmlRenderer htmlRenderer, ISideContentRenderer sideContentRenderer) - : Controller + : ServiceController { [HttpGet] public async Task Index() @@ -22,19 +21,13 @@ public async Task Index() if (startPageContent is null) { logger.LogCritical("Start page content not found"); - return RedirectToAction("Error"); + return RedirectToAction("Index", "Error"); } var model = await Map(startPageContent); return View(model); } - [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] - public IActionResult Error() - { - return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); - } - private async Task Map(StartPage startPageContent) { return new StartPageModel diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/QualificationDetailsController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/QualificationDetailsController.cs index 88358db8..6423c2fa 100644 --- a/src/Dfe.EarlyYearsQualification.Web/Controllers/QualificationDetailsController.cs +++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/QualificationDetailsController.cs @@ -1,6 +1,7 @@ using Dfe.EarlyYearsQualification.Content.Entities; using Dfe.EarlyYearsQualification.Content.Renderers.Entities; using Dfe.EarlyYearsQualification.Content.Services; +using Dfe.EarlyYearsQualification.Web.Controllers.Base; using Dfe.EarlyYearsQualification.Web.Models.Content; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; @@ -12,7 +13,7 @@ public class QualificationDetailsController( ILogger logger, IContentService contentService, IGovUkInsetTextRenderer renderer) - : Controller + : ServiceController { [HttpGet] public IActionResult Get() @@ -32,7 +33,7 @@ public async Task Index(string qualificationId) if (detailsPageContent is null) { logger.LogError("No content for the qualification details page"); - return RedirectToAction("Error", "Home"); + return RedirectToAction("Index", "Error"); } var qualification = await contentService.GetQualificationById(qualificationId); @@ -42,7 +43,7 @@ public async Task Index(string qualificationId) logger.LogError("Could not find details for qualification with ID: {QualificationId}", loggedQualificationId); - return RedirectToAction("Error", "Home"); + return RedirectToAction("Index", "Error"); } var model = await Map(qualification, detailsPageContent); diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/QuestionsController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/QuestionsController.cs index abd04c8d..8a8ee765 100644 --- a/src/Dfe.EarlyYearsQualification.Web/Controllers/QuestionsController.cs +++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/QuestionsController.cs @@ -3,6 +3,7 @@ using Dfe.EarlyYearsQualification.Content.Renderers.Entities; using Dfe.EarlyYearsQualification.Content.Services; using Dfe.EarlyYearsQualification.Web.Constants; +using Dfe.EarlyYearsQualification.Web.Controllers.Base; using Dfe.EarlyYearsQualification.Web.Models.Content; using Microsoft.AspNetCore.Mvc; @@ -13,7 +14,7 @@ public class QuestionsController( ILogger logger, IContentService contentService, IHtmlRenderer renderer) - : Controller + : ServiceController { private const string Questions = "Questions"; @@ -99,7 +100,7 @@ private async Task GetView(string questionPageId, string actionNa if (questionPage is null) { logger.LogError("No content for the question page"); - return RedirectToAction("Error", "Home"); + return RedirectToAction("Index", "Error"); } var model = await Map(new QuestionModel(), questionPage, actionName, controllerName); diff --git a/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs b/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs new file mode 100644 index 00000000..d90d101c --- /dev/null +++ b/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs @@ -0,0 +1,105 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Dfe.EarlyYearsQualification.Web.Filters; + +/// +/// Filter attribute that will check the HTTP request for a cookie whose value matches +/// one of the configured keys. Up to four allowable keys are set up in the service config: +/// +/// "ServiceAccess": +/// { +/// "IsPublic": false, +/// "Keys": [ +/// "Key-value-1", +/// "Key-value-2" +/// "Key-value-3" +/// "Key-value-4" +/// ] +/// } +/// +/// "IsPublic" defaults to false. If "IsPublic" is true, it is more efficient to +/// add to the pipeline instead. +/// +/// +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public class ChallengeResourceFilterAttribute( + ILogger logger, + IConfiguration configuration) + : Attribute, IChallengeResourceFilterAttribute +{ + public const string AuthSecretCookieName = "auth-secret"; + + private const bool RedirectsArePermanent = false; + private const bool RedirectsPreserveMethod = false; + + private bool AllowPublicAccess + { + get { return configuration.GetValue("ServiceAccess:IsPublic"); } + } + + private IEnumerable ConfiguredKeys + { + get + { + var keys = configuration + .GetSection("ServiceAccess") + .GetSection("Keys") + .Get(); + + return keys == null ? [] : keys.Where(k => !string.IsNullOrWhiteSpace(k)); + } + } + + public void OnResourceExecuting(ResourceExecutingContext context) + { + if (AllowPublicAccess) + { + return; + } + + if (!ConfiguredKeys.Any()) + { + logger.LogError("Service access keys not configured"); + context.Result = new RedirectToActionResult("Index", + "Error", + new { }, + RedirectsArePermanent, + RedirectsPreserveMethod); + return; + } + + var cookieIsPresent = context.HttpContext.Request.Cookies.ContainsKey(AuthSecretCookieName); + + if (cookieIsPresent && ConfiguredKeys.Contains(context.HttpContext.Request.Cookies[AuthSecretCookieName])) + { + return; + } + + var warningMessage = $"Access denied by {nameof(ChallengeResourceFilterAttribute)}"; + + if (cookieIsPresent) + { + warningMessage += " (incorrect value submitted)"; + } + + logger.LogWarning(warningMessage); + + var requestedPath = context.HttpContext.Request.Path; + + context.Result = new RedirectToActionResult("Index", + "Challenge", + new + { + redirectAddress = requestedPath + }, + RedirectsArePermanent, + RedirectsPreserveMethod); + } + + public void OnResourceExecuted(ResourceExecutedContext context) + { + // do nothing + } +} \ No newline at end of file diff --git a/src/Dfe.EarlyYearsQualification.Web/Filters/IChallengeResourceFilterAttribute.cs b/src/Dfe.EarlyYearsQualification.Web/Filters/IChallengeResourceFilterAttribute.cs new file mode 100644 index 00000000..42558448 --- /dev/null +++ b/src/Dfe.EarlyYearsQualification.Web/Filters/IChallengeResourceFilterAttribute.cs @@ -0,0 +1,5 @@ +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Dfe.EarlyYearsQualification.Web.Filters; + +public interface IChallengeResourceFilterAttribute : IResourceFilter; \ No newline at end of file diff --git a/src/Dfe.EarlyYearsQualification.Web/Filters/NoChallengeResourceFilterAttribute.cs b/src/Dfe.EarlyYearsQualification.Web/Filters/NoChallengeResourceFilterAttribute.cs new file mode 100644 index 00000000..978a46c9 --- /dev/null +++ b/src/Dfe.EarlyYearsQualification.Web/Filters/NoChallengeResourceFilterAttribute.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Dfe.EarlyYearsQualification.Web.Filters; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public class NoChallengeResourceFilterAttribute : Attribute, IChallengeResourceFilterAttribute +{ + public void OnResourceExecuting(ResourceExecutingContext context) + { + // do nothing + } + + public void OnResourceExecuted(ResourceExecutedContext context) + { + // do nothing + } +} \ No newline at end of file diff --git a/src/Dfe.EarlyYearsQualification.Web/Models/ChallengeModel.cs b/src/Dfe.EarlyYearsQualification.Web/Models/ChallengeModel.cs new file mode 100644 index 00000000..3a37c860 --- /dev/null +++ b/src/Dfe.EarlyYearsQualification.Web/Models/ChallengeModel.cs @@ -0,0 +1,8 @@ +namespace Dfe.EarlyYearsQualification.Web.Models; + +public class ChallengeModel +{ + public string? Value { get; set; } + + public string RedirectAddress { get; set; } = "/"; +} \ No newline at end of file diff --git a/src/Dfe.EarlyYearsQualification.Web/Program.cs b/src/Dfe.EarlyYearsQualification.Web/Program.cs index a7e0a8af..b2f54546 100644 --- a/src/Dfe.EarlyYearsQualification.Web/Program.cs +++ b/src/Dfe.EarlyYearsQualification.Web/Program.cs @@ -4,6 +4,7 @@ using Dfe.EarlyYearsQualification.Content.Extensions; using Dfe.EarlyYearsQualification.Content.Services; using Dfe.EarlyYearsQualification.Mock.Extensions; +using Dfe.EarlyYearsQualification.Web.Filters; using Dfe.EarlyYearsQualification.Web.Security; using Dfe.EarlyYearsQualification.Web.Services.CookieService; using Microsoft.AspNetCore.DataProtection; @@ -51,11 +52,24 @@ builder.Services.AddTransient(); builder.Services.AddSingleton(); -builder.Services.AddScoped(x => { - var actionContext = x.GetRequiredService().ActionContext; - var factory = x.GetRequiredService(); - return factory.GetUrlHelper(actionContext!); -}); +builder.Services.AddScoped(x => + { + var actionContext = x.GetRequiredService().ActionContext; + var factory = x.GetRequiredService(); + return factory.GetUrlHelper(actionContext!); + }); + +var accessIsChallenged = !builder.Configuration.GetValue("ServiceAccess:IsPublic"); +// ...by default, challenge the user for the secret value unless that's explicitly turned off + +if (accessIsChallenged) +{ + builder.Services.AddScoped(); +} +else +{ + builder.Services.AddSingleton(); +} builder.Services.AddStaticRobotsTxt(robotsTxtOptions => robotsTxtOptions.DenyAll()); @@ -84,7 +98,7 @@ "default", "{controller=Home}/{action=Index}/{id?}"); -app.Run(); +await app.RunAsync(); [ExcludeFromCodeCoverage] diff --git a/src/Dfe.EarlyYearsQualification.Web/Views/Challenge/EntryForm.cshtml b/src/Dfe.EarlyYearsQualification.Web/Views/Challenge/EntryForm.cshtml new file mode 100644 index 00000000..95844598 --- /dev/null +++ b/src/Dfe.EarlyYearsQualification.Web/Views/Challenge/EntryForm.cshtml @@ -0,0 +1,25 @@ +@model ChallengeModel + +@{ + Layout = null; +} + + + + + + Challenge + + +

+ If you have been invited to view this preview service, you will have been + given a value that will grant access. Please type in the value and click 'Submit'. +

+@using (Html.BeginForm("Post", "Challenge", new { }, FormMethod.Post)) +{ + + + +} + + \ No newline at end of file diff --git a/terraform/README.md b/terraform/README.md index 34c748d3..c67654db 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -56,11 +56,16 @@ This module provisions a new Azure Resource Group that assembles together the in | [kv\_certificate\_subject](#input\_kv\_certificate\_subject) | Subject of the Certificate | `string` | n/a | yes | | [resource\_name\_prefix](#input\_resource\_name\_prefix) | Prefix for resource names | `string` | n/a | yes | | [tracking\_id](#input\_tracking\_id) | Google Tag Manager tracking ID | `string` | n/a | yes | +| [webapp\_access\_is\_public](#input\_webapp\_access\_is\_public) | Web app service is public, and access is unchallenged | `bool` | `false` | no | +| [webapp\_access\_key\_1](#input\_webapp\_access\_key\_1) | Web app access key for invited access 1 | `string` | n/a | yes | +| [webapp\_access\_key\_2](#input\_webapp\_access\_key\_2) | Web app access key for invited access 2 | `string` | n/a | yes | | [webapp\_docker\_image](#input\_webapp\_docker\_image) | Docker Image to deploy | `string` | n/a | yes | | [webapp\_docker\_image\_tag](#input\_webapp\_docker\_image\_tag) | Tag for the Docker Image | `string` | `"latest"` | no | | [webapp\_docker\_registry\_url](#input\_webapp\_docker\_registry\_url) | URL to the Docker Registry | `string` | n/a | yes | +| [webapp\_e2e\_access\_key](#input\_webapp\_e2e\_access\_key) | Web app access key for automated end-to-end tests | `string` | n/a | yes | | [webapp\_name](#input\_webapp\_name) | Name for the Web Application | `string` | n/a | yes | | [webapp\_slot\_name](#input\_webapp\_slot\_name) | Name for the slot for the Web Application | `string` | `"green"` | no | +| [webapp\_team\_access\_key](#input\_webapp\_team\_access\_key) | Web app access key for the service team | `string` | n/a | yes | | [webapp\_worker\_count](#input\_webapp\_worker\_count) | Number of Workers for the App Service Plan | `string` | `1` | no | ## Outputs diff --git a/terraform/local.tf b/terraform/local.tf index 9fe8f079..c487db2d 100644 --- a/terraform/local.tf +++ b/terraform/local.tf @@ -16,6 +16,11 @@ locals { "KeyVault__Endpoint" = "https://${var.resource_name_prefix}-kv.vault.azure.net/" "ContentfulOptions__UsePreviewApi" = var.contentful_use_preview_api "WEBSITES_PORT" = "8080" + "ServiceAccess__IsPublic" = var.webapp_access_is_public + "ServiceAccess__Keys__0" = var.webapp_e2e_access_key + "ServiceAccess__Keys__1" = var.webapp_team_access_key + "ServiceAccess__Keys__2" = var.webapp_access_key_1 + "ServiceAccess__Keys__3" = var.webapp_access_key_2 } webapp_slot_app_settings = { @@ -26,5 +31,10 @@ locals { "KeyVault__Endpoint" = "https://${var.resource_name_prefix}-kv.vault.azure.net/" "ContentfulOptions__UsePreviewApi" = var.contentful_use_preview_api "WEBSITES_PORT" = "8080" + "ServiceAccess__IsPublic" = var.webapp_access_is_public + "ServiceAccess__Keys__0" = var.webapp_e2e_access_key + "ServiceAccess__Keys__1" = var.webapp_team_access_key + "ServiceAccess__Keys__2" = var.webapp_access_key_1 + "ServiceAccess__Keys__3" = var.webapp_access_key_2 } -} \ No newline at end of file +} diff --git a/terraform/main.tf b/terraform/main.tf index fa69a2c6..6e660340 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -79,6 +79,7 @@ module "webapp" { webapp_docker_registry_url = var.webapp_docker_registry_url webapp_session_cookie_name = "_early_years_qualification_session" webapp_cookie_preference_name = "cookies_preferences_set" + webapp_cookie_auth_secret_name = "auth-secret" webapp_custom_domain_name = var.custom_domain_name webapp_custom_domain_cert_secret_label = var.kv_certificate_label webapp_health_check_path = "/health" @@ -90,4 +91,4 @@ module "webapp" { kv_mi_id = module.network.kv_mi_id tags = local.common_tags depends_on = [module.network] -} \ No newline at end of file +} diff --git a/terraform/modules/azure-web/README.md b/terraform/modules/azure-web/README.md index 1b7ea852..3919dec0 100644 --- a/terraform/modules/azure-web/README.md +++ b/terraform/modules/azure-web/README.md @@ -59,6 +59,7 @@ No modules. | [tags](#input\_tags) | Resource tags | `map(string)` | n/a | yes | | [webapp\_admin\_email\_address](#input\_webapp\_admin\_email\_address) | Email Address of the Admin | `string` | n/a | yes | | [webapp\_app\_settings](#input\_webapp\_app\_settings) | App Settings are exposed as environment variables | `map(string)` | n/a | yes | +| [webapp\_cookie\_auth\_secret\_name](#input\_webapp\_cookie\_auth\_secret\_name) | Name of the cookie holding the auth secret | `string` | n/a | yes | | [webapp\_cookie\_preference\_name](#input\_webapp\_cookie\_preference\_name) | Name of the user's cookie preference cookie | `string` | n/a | yes | | [webapp\_custom\_domain\_cert\_secret\_label](#input\_webapp\_custom\_domain\_cert\_secret\_label) | Label for the Certificate | `string` | n/a | yes | | [webapp\_custom\_domain\_name](#input\_webapp\_custom\_domain\_name) | Custom domain hostname | `string` | n/a | yes | diff --git a/terraform/modules/azure-web/app-gateway.tf b/terraform/modules/azure-web/app-gateway.tf index 52dd2e15..afbe122e 100644 --- a/terraform/modules/azure-web/app-gateway.tf +++ b/terraform/modules/azure-web/app-gateway.tf @@ -37,6 +37,12 @@ resource "azurerm_web_application_firewall_policy" "agw_wafp" { selector = var.webapp_cookie_preference_name selector_match_operator = "Equals" } + + exclusion { + match_variable = "RequestCookieNames" + selector = var.webapp_cookie_auth_secret_name + selector_match_operator = "Equals" + } } policy_settings { @@ -192,4 +198,4 @@ resource "azurerm_monitor_diagnostic_setting" "agw_logs_monitor" { lifecycle { ignore_changes = [metric] } -} \ No newline at end of file +} diff --git a/terraform/modules/azure-web/variables.tf b/terraform/modules/azure-web/variables.tf index ac492cab..e6e3c475 100644 --- a/terraform/modules/azure-web/variables.tf +++ b/terraform/modules/azure-web/variables.tf @@ -90,6 +90,11 @@ variable "webapp_cookie_preference_name" { type = string } +variable "webapp_cookie_auth_secret_name" { + description = "Name of the cookie holding the auth secret" + type = string +} + variable "webapp_health_check_path" { default = null description = "Path to health check endpoint" @@ -146,4 +151,4 @@ variable "kv_mi_id" { variable "tags" { description = "Resource tags" type = map(string) -} \ No newline at end of file +} diff --git a/terraform/modules/azure-web/web-app.tf b/terraform/modules/azure-web/web-app.tf index ae494321..7d0a599d 100644 --- a/terraform/modules/azure-web/web-app.tf +++ b/terraform/modules/azure-web/web-app.tf @@ -405,4 +405,4 @@ resource "azurerm_app_service_certificate_binding" "webapp_custom_domain_cert_bi hostname_binding_id = azurerm_app_service_custom_hostname_binding.webapp_custom_domain[0].id certificate_id = azurerm_app_service_certificate.webapp_custom_domain_cert[0].id ssl_state = "SniEnabled" -} \ No newline at end of file +} diff --git a/terraform/variables.tf b/terraform/variables.tf index 099604c3..faf8b9e9 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -90,6 +90,32 @@ variable "webapp_slot_name" { type = string } +variable "webapp_access_is_public" { + description = "Web app service is public, and access is unchallenged" + default = false + type = bool +} + +variable "webapp_e2e_access_key" { + description = "Web app access key for automated end-to-end tests" + type = string +} + +variable "webapp_team_access_key" { + description = "Web app access key for the service team" + type = string +} + +variable "webapp_access_key_1" { + description = "Web app access key for invited access 1" + type = string +} + +variable "webapp_access_key_2" { + description = "Web app access key for invited access 2" + type = string +} + variable "webapp_docker_registry_url" { description = "URL to the Docker Registry" type = string @@ -134,4 +160,4 @@ variable "contentful_space_id" { variable "contentful_use_preview_api" { description = "Boolean used to set whether content is preview or published" type = bool -} \ No newline at end of file +} diff --git a/tests/Dfe.EarlyYearsQualification.AccessibilityTests/.pa11yci-ubuntu.js b/tests/Dfe.EarlyYearsQualification.AccessibilityTests/.pa11yci-ubuntu.js new file mode 100644 index 00000000..f20c793a --- /dev/null +++ b/tests/Dfe.EarlyYearsQualification.AccessibilityTests/.pa11yci-ubuntu.js @@ -0,0 +1,34 @@ +var config = { + defaults: { + standard: 'WCAG2AA', + chromeLaunchConfig: { + executablePath: "/usr/bin/google-chrome" + }, + headers: { + Cookie: 'auth-secret=${AUTH_SECRET}' + } + }, + urls: [ + "http://localhost:5000/", + "http://localhost:5000/qualifications/qualification-details/eyq-240", + "http://localhost:5000/questions/where-was-the-qualification-awarded", + "http://localhost:5000/advice/qualification-outside-the-united-kingdom", + "http://localhost:5000/questions/when-was-the-qualification-started", + "http://localhost:5000/questions/what-level-is-the-qualification" + ] +}; + +function createPa11yCiConfiguration(urls, defaults) { + + console.error('Env:', process.env.AUTH_SECRET); + + defaults.headers.Cookie = defaults.headers.Cookie.replace('${AUTH_SECRET}', process.env.AUTH_SECRET) + + return { + defaults: defaults, + urls: urls + } +}; + +// Important ~ call the function, don't just return a reference to it! +module.exports = createPa11yCiConfiguration(config.urls, config.defaults); \ No newline at end of file diff --git a/tests/Dfe.EarlyYearsQualification.AccessibilityTests/.pa11yci-ubuntu.json b/tests/Dfe.EarlyYearsQualification.AccessibilityTests/.pa11yci-ubuntu.json deleted file mode 100644 index e15a859c..00000000 --- a/tests/Dfe.EarlyYearsQualification.AccessibilityTests/.pa11yci-ubuntu.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "defaults": { - "chromeLaunchConfig": { - "executablePath": "/usr/bin/google-chrome" - } - }, - "urls": [ - "http://localhost:5000/", - "http://localhost:5000/qualifications/qualification-details/eyq-240", - "http://localhost:5000/questions/where-was-the-qualification-awarded", - "http://localhost:5000/advice/qualification-outside-the-united-kingdom", - "http://localhost:5000/questions/when-was-the-qualification-started", - "http://localhost:5000/questions/what-level-is-the-qualification" - ] -} \ No newline at end of file diff --git a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress.config.js b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress.config.js index 871b69f4..10e64fa0 100644 --- a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress.config.js +++ b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress.config.js @@ -1,10 +1,19 @@ const { defineConfig } = require("cypress"); module.exports = defineConfig({ + env: { + auth_secret: 'CX' // dummy value: pass in using Cypress command line --env auth_secret=an-acceptable-secret-value + }, e2e: { baseUrl: "http://127.0.0.1:5025/", setupNodeEvents(on, config) { + on('task', { + log(message) { + console.log(message) + return null + } + }) // implement node event listeners here - }, + } }, }); diff --git a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/journey/deny-public-access.cy.js b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/journey/deny-public-access.cy.js new file mode 100644 index 00000000..054d60cc --- /dev/null +++ b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/journey/deny-public-access.cy.js @@ -0,0 +1,15 @@ +describe('A spec used to check a new user is challenged to enter the secret', () => { + + it("should redirect the user to the challenge page", () => { + + cy.visit("/"); + + cy.location().should((loc) => { + expect(loc.pathname).to.eq('/Challenge'); + expect(loc.search).to.eq('?redirectAddress=%2F') + }) + + cy.get('#redirectAddress').should("have.value", '/'); + cy.get('#value').should("be.empty"); + }) +}) \ No newline at end of file diff --git a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/journey/journey-spec.cy.js b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/journey/journey-spec.cy.js index e70152af..dace06b0 100644 --- a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/journey/journey-spec.cy.js +++ b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/journey/journey-spec.cy.js @@ -1,5 +1,6 @@ describe('A spec used to test the various routes through the journey', () => { beforeEach(() => { + cy.setCookie('auth-secret', Cypress.env('auth_secret')); cy.visit("/"); cy.get('.govuk-button--start').should('exist'); }) diff --git a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/accessibility-specification-spec.cy.js b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/accessibility-specification-spec.cy.js index 732b31b4..f4775f40 100644 --- a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/accessibility-specification-spec.cy.js +++ b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/accessibility-specification-spec.cy.js @@ -1,5 +1,8 @@ describe("A spec that tests the accessibility statement page", () => { - + beforeEach(() => { + cy.setCookie('auth-secret', Cypress.env('auth_secret')); + }) + // Mock details found in Dfe.EarlyYearsQualification.Mock.Content.MockContentfulService. it("Checks the heading and content are present", () => { cy.visit("/accessibility-statement"); diff --git a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/advice-spec.cy.js b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/advice-spec.cy.js index 696356ef..28c4500d 100644 --- a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/advice-spec.cy.js +++ b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/advice-spec.cy.js @@ -1,5 +1,8 @@ describe("A spec that tests advice pages", () => { - + beforeEach(() => { + cy.setCookie('auth-secret', Cypress.env('auth_secret')); + }) + // Mock details found in Dfe.EarlyYearsQualification.Mock.Content.MockContentfulService. it("Checks the qualification details are on the page", () => { cy.visit("/advice/qualification-outside-the-united-kingdom"); diff --git a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/cookies-spec.cy.js b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/cookies-spec.cy.js index e9e71873..9d47ae50 100644 --- a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/cookies-spec.cy.js +++ b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/cookies-spec.cy.js @@ -2,6 +2,7 @@ describe("A spec that tests the cookies page", () => { // Mock details found in Dfe.EarlyYearsQualification.Mock.Content.MockContentfulService. beforeEach(() => { + cy.setCookie('auth-secret', Cypress.env('auth_secret')); cy.visit("/cookies"); }); @@ -14,8 +15,8 @@ describe("A spec that tests the cookies page", () => { cy.get("#test-option-value-1").should("exist"); cy.get("#test-option-value-2").should("exist"); - cy.get("label[for='test-option-value-1']").should("contain.text","Test Option Label 1"); - cy.get("label[for='test-option-value-2']").should("contain.text","Test Option Label 2"); + cy.get("label[for='test-option-value-1']").should("contain.text", "Test Option Label 1"); + cy.get("label[for='test-option-value-2']").should("contain.text", "Test Option Label 2"); cy.get("#cookies-choice-error") .should("not.be.visible") @@ -27,25 +28,25 @@ describe("A spec that tests the cookies page", () => { describe("Check the functionality of the page", () => { it("Checks that the radio button validation is working", () => { cy.get('button[id="cookies-button"]').click(); - + cy.get("#cookies-set-banner").should("not.exist"); - + cy.get("#cookies-choice-error").should("be.visible"); }); - + ["test-option-value-1", "test-option-value-2"].forEach((option) => { it(`Checks that selecting ${option} reveals success banner`, () => { cy.get(`#${option}`).click(); cy.get('button[id="cookies-button"]').click(); - + cy.get("#cookies-set-banner").should("be.visible"); - + // Seen as the success banner doesn't exist in the rendered HTML by default; we have to check the content once we expect the heading to be visible. - cy.get("#cookies-set-banner-heading").should("contain.text","Test Success Banner Heading"); - cy.get("#cookies-set-banner-content").should("contain.text","Test Success Banner Content"); - + cy.get("#cookies-set-banner-heading").should("contain.text", "Test Success Banner Heading"); + cy.get("#cookies-set-banner-content").should("contain.text", "Test Success Banner Content"); + cy.get("#cookies-choice-error").should("not.be.visible"); }); }); diff --git a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/home-spec.cy.js b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/home-spec.cy.js index 538833c2..4d2030e5 100644 --- a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/home-spec.cy.js +++ b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/home-spec.cy.js @@ -1,11 +1,13 @@ describe("A spec used to test the home page", () => { beforeEach(() => { - cy.visit("/"); + cy.setCookie('auth-secret', Cypress.env('auth_secret')); }) // Mock details found in Dfe.EarlyYearsQualification.Mock.Content.MockContentfulService. it("Checks the page contains the relevant components", () => { + cy.visit("/"); + cy.get(".govuk-heading-xl").should("contain.text", "Test Header"); cy.get("#pre-cta-content p").should("contain.text", "This is the pre cta content"); cy.get(".govuk-button--start").should("contain.text", "Start Button Text"); @@ -15,6 +17,8 @@ describe("A spec used to test the home page", () => { }) it("Checks Crown copyright link text", () => { + cy.visit("/"); + cy.get(".govuk-footer__copyright-logo").first().should("contain.text", "Crown copyright") }) }) \ No newline at end of file diff --git a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/qualification-details-spec.cy.js b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/qualification-details-spec.cy.js index 66c04bb5..002f193f 100644 --- a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/qualification-details-spec.cy.js +++ b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/qualification-details-spec.cy.js @@ -1,17 +1,19 @@ describe("A spec used to test the qualification details page", () => { - beforeEach(() => { - cy.visit("/qualifications/qualification-details/eyq-240"); - }) - - // Mock details found in Dfe.EarlyYearsQualification.Mock.Content.MockContentfulService. - it("Checks the qualification details are on the page", () => { - cy.get("#qualification-name-value").should("contain.text", "T Level Technical Qualification in Education and Childcare (Specialism - Early Years Educator)"); - cy.get("#awarding-qualification-value").should("contain.text", "NCFE"); - cy.get("#qualification-level-value").should("contain.text", "3"); - cy.get("#qualification-number-value").should("contain.text", "603/5829/4"); - cy.get("#from-which-year-value").should("contain.text", "2020"); - cy.get("#notes-value").should("contain.text", "The course must be assessed within the EYFS in an Early Years setting in England. Please note that the name of this qualification changed in February 2023. Qualifications achieved under either name are full and relevant provided that the start date for the qualification aligns with the date of the name change."); - cy.get("#additional-requirements-value").should("contain.text", "Additional notes"); - }) - }) \ No newline at end of file + beforeEach(() => { + cy.setCookie('auth-secret', Cypress.env('auth_secret')); + }) + + // Mock details found in Dfe.EarlyYearsQualification.Mock.Content.MockContentfulService. + it("Checks the qualification details are on the page", () => { + cy.visit("/qualifications/qualification-details/eyq-240"); + + cy.get("#qualification-name-value").should("contain.text", "T Level Technical Qualification in Education and Childcare (Specialism - Early Years Educator)"); + cy.get("#awarding-qualification-value").should("contain.text", "NCFE"); + cy.get("#qualification-level-value").should("contain.text", "3"); + cy.get("#qualification-number-value").should("contain.text", "603/5829/4"); + cy.get("#from-which-year-value").should("contain.text", "2020"); + cy.get("#notes-value").should("contain.text", "The course must be assessed within the EYFS in an Early Years setting in England. Please note that the name of this qualification changed in February 2023. Qualifications achieved under either name are full and relevant provided that the start date for the qualification aligns with the date of the name change."); + cy.get("#additional-requirements-value").should("contain.text", "Additional notes"); + }) +}) \ No newline at end of file diff --git a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/question-spec.cy.js b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/question-spec.cy.js index e5902197..00e2504d 100644 --- a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/question-spec.cy.js +++ b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/pages/question-spec.cy.js @@ -1,5 +1,8 @@ describe("A spec that tests question pages", () => { - + beforeEach(() => { + cy.setCookie('auth-secret', Cypress.env('auth_secret')); + }) + // Mock details found in Dfe.EarlyYearsQualification.Mock.Content.MockContentfulService. it("Checks the content on where-was-the-qualification-awarded page", () => { cy.visit("/questions/where-was-the-qualification-awarded"); @@ -17,7 +20,7 @@ describe("A spec that tests question pages", () => { cy.get('button[id="question-submit"]').click(); cy.location().should((loc) => { - expect(loc.pathname).to.eq("/questions/where-was-the-qualification-awarded"); + expect(loc.pathname).to.eq("/questions/where-was-the-qualification-awarded"); }) cy.get('#option-error').should("exist"); @@ -47,7 +50,7 @@ describe("A spec that tests question pages", () => { cy.get('button[id="question-submit"]').click(); cy.location().should((loc) => { - expect(loc.pathname).to.eq("/questions/what-level-is-the-qualification"); + expect(loc.pathname).to.eq("/questions/what-level-is-the-qualification"); }) cy.get('#option-error').should("exist"); diff --git a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/shared/cookies-banner-spec.cy.js b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/shared/cookies-banner-spec.cy.js index 918fcb09..ee137cd3 100644 --- a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/shared/cookies-banner-spec.cy.js +++ b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/shared/cookies-banner-spec.cy.js @@ -1,12 +1,16 @@ import { pages } from "./urls-to-check"; describe("A spec that tests that the cookies banner shows on all pages", () => { + beforeEach(() => { + cy.setCookie('auth-secret', Cypress.env('auth_secret')); + }) + // Mock details found in Dfe.EarlyYearsQualification.Mock.Content.MockContentfulService. pages.forEach((option) => { it(`Checks that the cookies banner is present at the URL: ${option}`, () => { cy.visit(option); - + cy.get("#choose-cookies-preference").should("be.visible"); cy.get("#cookies-preference-chosen").should("not.exist"); @@ -26,7 +30,7 @@ describe("A spec that tests that the cookies banner shows on all pages", () => { .should('have.property', 'value', "%7B%22HasApproved%22%3Atrue%2C%22IsVisible%22%3Atrue%2C%22IsRejected%22%3Afalse%7D"); cy.get("#cookies-banner-pref-chosen-content").should("contain.text", "This is the accepted cookie content"); - + cy.get('button[id="hide-cookie-banner-button"]').click(); cy.get("#choose-cookies-preference").should("not.exist"); @@ -48,7 +52,7 @@ describe("A spec that tests that the cookies banner shows on all pages", () => { .should('have.property', 'value', "%7B%22HasApproved%22%3Afalse%2C%22IsVisible%22%3Atrue%2C%22IsRejected%22%3Atrue%7D"); cy.get("#cookies-banner-pref-chosen-content").should("contain.text", "This is the rejected cookie content"); - + cy.get('button[id="hide-cookie-banner-button"]').click(); cy.get("#choose-cookies-preference").should("not.exist"); diff --git a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/shared/phase-banner-spec.cy.js b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/shared/phase-banner-spec.cy.js index bc9a83d2..cd323431 100644 --- a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/shared/phase-banner-spec.cy.js +++ b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/shared/phase-banner-spec.cy.js @@ -1,14 +1,18 @@ import { pages } from "./urls-to-check"; describe("A spec that tests the phase banner is showing on all pages", () => { - + // Mock details found in Dfe.EarlyYearsQualification.Mock.Content.MockContentfulService. pages.forEach((option) => { + beforeEach(() => { + cy.setCookie('auth-secret', Cypress.env('auth_secret')); + }) + it(`Checks that the phase banner is present at the URL: ${option}`, () => { cy.visit(option); - + cy.get(".govuk-phase-banner").should("be.visible"); cy.get(".govuk-phase-banner__content__tag").should("contain.text", "Test phase banner name"); diff --git a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/shared/security-header-spec.cy.js b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/shared/security-header-spec.cy.js index d70ec823..40084939 100644 --- a/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/shared/security-header-spec.cy.js +++ b/tests/Dfe.EarlyYearsQualification.E2ETests/cypress/e2e/shared/security-header-spec.cy.js @@ -1,6 +1,9 @@ import { pagesWithForms, pagesWithoutForms } from "./urls-to-check"; describe("A spec that checks for security headers in the response", () => { + beforeEach(() => { + cy.setCookie('auth-secret', Cypress.env('auth_secret')); + }) pagesWithForms.forEach((page) => { it(`pages with forms and no cookie banner - ${page} contains the expected response headers`, () => { diff --git a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/AccessibilityStatementControllerTests.cs b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/AccessibilityStatementControllerTests.cs index 6f1e3efb..81ae1220 100644 --- a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/AccessibilityStatementControllerTests.cs +++ b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/AccessibilityStatementControllerTests.cs @@ -33,7 +33,7 @@ public async Task Index_NoContent_NavigatesToErrorPageAsync() var result = await controller.Index(); result.Should().NotBeNull(); - result.Should().BeEquivalentTo(new RedirectToActionResult("Error", "Home", null)); + result.Should().BeEquivalentTo(new RedirectToActionResult("Index", "Error", null)); mockLogger.VerifyError("No content for the accessibility statement page"); } diff --git a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/AdviceControllerTests.cs b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/AdviceControllerTests.cs index a31977f9..25d0fbf0 100644 --- a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/AdviceControllerTests.cs +++ b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/AdviceControllerTests.cs @@ -36,8 +36,8 @@ public async Task QualificationOutsideTheUnitedKingdom_ContentServiceReturnsNoAd resultType.Should().NotBeNull(); - resultType!.ActionName.Should().Be("Error"); - resultType.ControllerName.Should().Be("Home"); + resultType!.ActionName.Should().Be("Index"); + resultType.ControllerName.Should().Be("Error"); mockLogger.VerifyError("No content for the advice page"); } diff --git a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ChallengeControllerTests.cs b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ChallengeControllerTests.cs new file mode 100644 index 00000000..56e15275 --- /dev/null +++ b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ChallengeControllerTests.cs @@ -0,0 +1,158 @@ +using Dfe.EarlyYearsQualification.Web.Controllers; +using Dfe.EarlyYearsQualification.Web.Filters; +using Dfe.EarlyYearsQualification.Web.Models; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Dfe.EarlyYearsQualification.UnitTests.Controllers; + +[TestClass] +public class ChallengeControllerTests +{ + [TestMethod] + public async Task GetChallenge_Returns_Page() + { + const string from = "/"; + + var mockUrlHelper = new Mock(); + mockUrlHelper.Setup(u => u.IsLocalUrl(It.IsAny())) + .Returns(true); + + var controller = new ChallengeController(NullLogger.Instance, + mockUrlHelper.Object); + + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + + var result = await controller.Index(new ChallengeModel { RedirectAddress = "/", Value = null }); + + result.Should().BeAssignableTo(); + + var content = (ViewResult)result; + + content.Model.Should().BeAssignableTo() + .Which + .RedirectAddress.Should().Be(from); + } + + [TestMethod] + public async Task GetChallenge_NonLocalFrom_Returns_Page_With_BaseFrom() + { + const string from = "https://google.co.uk"; + + var mockUrlHelper = new Mock(); + mockUrlHelper.Setup(u => u.IsLocalUrl(It.IsAny())) + .Returns(false); + + var controller = new ChallengeController(NullLogger.Instance, + mockUrlHelper.Object); + + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + + var result = await controller.Index(new ChallengeModel { RedirectAddress = from, Value = null }); + + result.Should().BeAssignableTo(); + + var content = (ViewResult)result; + + content.Model.Should().BeAssignableTo() + .Which + .RedirectAddress.Should().Be("/"); + } + + [TestMethod] + public async Task PostChallenge_WithCorrectValue_RedirectsWithCookie() + { + const string from = "/cookies"; + const string accessKey = "CX"; + + var mockUrlHelper = new Mock(); + mockUrlHelper.Setup(u => u.IsLocalUrl(It.IsAny())) + .Returns(true); + + var cookies = new Dictionary(); + + var cookiesMock = new Mock(); + cookiesMock.Setup(c => + c.Append(ChallengeResourceFilterAttribute.AuthSecretCookieName, + accessKey)) + .Callback((string k, string v) => cookies.Add(k, v)); + + var mockContext = new Mock(); + mockContext.SetupGet(c => c.Response.Cookies).Returns(cookiesMock.Object); + + var controller = new ChallengeController(NullLogger.Instance, + mockUrlHelper.Object); + controller.ControllerContext = new ControllerContext + { + HttpContext = mockContext.Object + }; + + var result = await controller.Post(new ChallengeModel + { + RedirectAddress = from, + Value = accessKey + }); + + result.Should().BeAssignableTo(); + + var redirect = (RedirectResult)result; + redirect.Url.Should().Be("/cookies"); + + cookies.Should().ContainKey(ChallengeResourceFilterAttribute.AuthSecretCookieName); + cookies[ChallengeResourceFilterAttribute.AuthSecretCookieName].Should() + .Be(accessKey); + } + + [TestMethod] + public async Task PostChallenge_WithCorrectValue_ButNonLocalFrom_RedirectsWithCookie_ToBaseUrl() + { + const string from = "https://google.co.uk"; + const string accessKey = "CX"; + + var mockUrlHelper = new Mock(); + mockUrlHelper.Setup(u => u.IsLocalUrl(It.IsAny())) + .Returns(false); // NB: behaviour relies on UrlHelper correctly determining non-local URLs + + var cookies = new Dictionary(); + + var cookiesMock = new Mock(); + cookiesMock.Setup(c => + c.Append(ChallengeResourceFilterAttribute.AuthSecretCookieName, + accessKey)) + .Callback((string k, string v) => cookies.Add(k, v)); + + var mockContext = new Mock(); + mockContext.SetupGet(c => c.Response.Cookies).Returns(cookiesMock.Object); + + var controller = new ChallengeController(NullLogger.Instance, + mockUrlHelper.Object); + controller.ControllerContext = new ControllerContext + { + HttpContext = mockContext.Object + }; + + var result = await controller.Post(new ChallengeModel + { + RedirectAddress = from, + Value = accessKey + }); + + result.Should().BeAssignableTo(); + + var redirect = (RedirectResult)result; + redirect.Url.Should().Be("/"); + + cookies.Should().ContainKey(ChallengeResourceFilterAttribute.AuthSecretCookieName); + cookies[ChallengeResourceFilterAttribute.AuthSecretCookieName].Should() + .Be(accessKey); + } +} \ No newline at end of file diff --git a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ControllerAccessTests.cs b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ControllerAccessTests.cs new file mode 100644 index 00000000..49d5e1e7 --- /dev/null +++ b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ControllerAccessTests.cs @@ -0,0 +1,32 @@ +using System.Reflection; +using Dfe.EarlyYearsQualification.Web.Controllers; +using Dfe.EarlyYearsQualification.Web.Controllers.Base; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; + +namespace Dfe.EarlyYearsQualification.UnitTests.Controllers; + +[TestClass] +public class ControllerAccessTests +{ + [TestMethod] + public void OnlyPubliclyAccessibleControllers_Are_Challenge_Error_And_Health() + { + var unguardedControllerTypes = + Assembly.GetAssembly(typeof(HomeController))! + .GetTypes() + .Where(c => + c.IsSubclassOf(typeof(Controller)) + && !c.IsSubclassOf(typeof(ServiceController)) + && c != typeof(ServiceController)); + + unguardedControllerTypes.Should().HaveCount(3) + .And.Contain([ + typeof(ChallengeController), + typeof(ErrorController), + typeof(HealthController) + ], + $"To enable guarding access to the service, controllers should inherit {typeof(ServiceController).FullName}" + ); + } +} \ No newline at end of file diff --git a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/CookiesControllerTests.cs b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/CookiesControllerTests.cs index 952d34b5..4135d4cc 100644 --- a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/CookiesControllerTests.cs +++ b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/CookiesControllerTests.cs @@ -37,7 +37,7 @@ public async Task Index_NoContent_NavigatesToErrorPageAsync() var result = await controller.Index(); result.Should().NotBeNull(); - result.Should().BeEquivalentTo(new RedirectToActionResult("Error", "Home", null)); + result.Should().BeEquivalentTo(new RedirectToActionResult("Index", "Error", null)); mockLogger.VerifyError("No content for the cookies page"); } diff --git a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/HomeControllerTests.cs b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/HomeControllerTests.cs index dce65861..f8792f6f 100644 --- a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/HomeControllerTests.cs +++ b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/HomeControllerTests.cs @@ -33,7 +33,8 @@ public async Task Index_ContentServiceReturnsNoContent_RedirectsToErrorPage() var resultType = result as RedirectToActionResult; resultType.Should().NotBeNull(); - resultType!.ActionName.Should().Be("Error"); + resultType!.ActionName.Should().Be("Index"); + resultType.ControllerName.Should().Be("Error"); mockLogger.VerifyCritical("Start page content not found"); } diff --git a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/QualificationDetailsControllerTests.cs b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/QualificationDetailsControllerTests.cs index e3f40999..d81119b2 100644 --- a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/QualificationDetailsControllerTests.cs +++ b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/QualificationDetailsControllerTests.cs @@ -66,8 +66,8 @@ public async Task Index_ContentServiceReturnsNullDetailsPage_RedirectsToHomeErro var actionResult = (RedirectToActionResult)result; - actionResult.ActionName.Should().Be("Error"); - actionResult.ControllerName.Should().Be("Home"); + actionResult.ActionName.Should().Be("Index"); + actionResult.ControllerName.Should().Be("Error"); mockLogger.VerifyError("No content for the qualification details page"); } @@ -97,8 +97,8 @@ public async Task Index_ContentServiceReturnsNoQualification_RedirectsToErrorPag var resultType = result as RedirectToActionResult; resultType.Should().NotBeNull(); - resultType!.ActionName.Should().Be("Error"); - resultType.ControllerName.Should().Be("Home"); + resultType!.ActionName.Should().Be("Index"); + resultType.ControllerName.Should().Be("Error"); mockLogger.VerifyError("Could not find details for qualification with ID: eyq-145"); } diff --git a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/QuestionsControllerTests.cs b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/QuestionsControllerTests.cs index df89e2dc..e236c10f 100644 --- a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/QuestionsControllerTests.cs +++ b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/QuestionsControllerTests.cs @@ -39,8 +39,8 @@ public async Task WhereWasTheQualificationAwarded_ContentServiceReturnsNoQuestio var resultType = result as RedirectToActionResult; result.Should().NotBeNull(); - resultType!.ActionName.Should().Be("Error"); - resultType.ControllerName.Should().Be("Home"); + resultType!.ActionName.Should().Be("Index"); + resultType.ControllerName.Should().Be("Error"); mockLogger.VerifyError("No content for the question page"); } @@ -208,8 +208,8 @@ public async Task WhatLevelIsTheQualification_ContentServiceReturnsNoQuestionPag var resultType = result as RedirectToActionResult; result.Should().NotBeNull(); - resultType!.ActionName.Should().Be("Error"); - resultType.ControllerName.Should().Be("Home"); + resultType!.ActionName.Should().Be("Index"); + resultType.ControllerName.Should().Be("Error"); mockLogger.VerifyError("No content for the question page"); } diff --git a/tests/Dfe.EarlyYearsQualification.UnitTests/Filters/ChallengeResourceFilterAttributeTests.cs b/tests/Dfe.EarlyYearsQualification.UnitTests/Filters/ChallengeResourceFilterAttributeTests.cs new file mode 100644 index 00000000..1a4ac54a --- /dev/null +++ b/tests/Dfe.EarlyYearsQualification.UnitTests/Filters/ChallengeResourceFilterAttributeTests.cs @@ -0,0 +1,525 @@ +using Dfe.EarlyYearsQualification.UnitTests.Extensions; +using Dfe.EarlyYearsQualification.Web.Filters; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Dfe.EarlyYearsQualification.UnitTests.Filters; + +[TestClass] +public class ChallengeResourceFilterAttributeTests +{ + [TestMethod] + public void ExecuteFilter_NoSecretValue_RedirectsToChallenge() + { + const string accessKey = "CX"; + + var dic = new Dictionary + { + { "ServiceAccess:Keys:0", accessKey } + }; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(dic) + .Build(); + + var filter = new ChallengeResourceFilterAttribute(NullLogger.Instance, + configuration); + + var httpContext = new DefaultHttpContext + { + Request = + { + Scheme = "https", + Host = new HostString("localhost"), + Path = "/start" + } + }; + + var actionContext = new ActionContext(httpContext, + new RouteData(), + new ActionDescriptor(), + new ModelStateDictionary()); + + var resourceExecutingContext = new ResourceExecutingContext(actionContext, + new List(), + new List()); + + filter.OnResourceExecuting(resourceExecutingContext); + + var result = resourceExecutingContext.Result; + + result.Should().BeAssignableTo(); + + var redirect = (RedirectToActionResult)result!; + + redirect.ActionName.Should().Be("Index"); + redirect.ControllerName.Should().Be("Challenge"); + redirect.RouteValues.Should().ContainKey("redirectAddress"); + redirect.Permanent.Should().BeFalse(); + redirect.PreserveMethod.Should().BeFalse(); + } + + [TestMethod] + public void ExecuteFilter_NoSecretValue_LogsWarning() + { + const string accessKey = "CX"; + + var dic = new Dictionary + { + { "ServiceAccess:Keys:0", accessKey } + }; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(dic) + .Build(); + + var mockLogger = new Mock>(); + + var filter = new ChallengeResourceFilterAttribute(mockLogger.Object, configuration); + + var httpContext = new DefaultHttpContext + { + Request = + { + Scheme = "https", + Host = new HostString("localhost"), + Path = "/start" + } + }; + + var actionContext = new ActionContext(httpContext, + new RouteData(), + new ActionDescriptor(), + new ModelStateDictionary()); + + var resourceExecutingContext = new ResourceExecutingContext(actionContext, + new List(), + new List()); + + filter.OnResourceExecuting(resourceExecutingContext); + + mockLogger.VerifyWarning($"Access denied by {nameof(ChallengeResourceFilterAttribute)}"); + } + + [TestMethod] + public void ExecuteFilter_CorrectSecretValue1_PassesThrough() + { + const string accessKey = "CX"; + + var dic = new Dictionary + { + { "ServiceAccess:Keys:0", accessKey } + }; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(dic) + .Build(); + + var filter = new ChallengeResourceFilterAttribute(NullLogger.Instance, + configuration); + + var httpContext = new DefaultHttpContext + { + Request = + { + Scheme = "https", + Host = new HostString("localhost"), + Path = "/start" + } + }; + + var cookie = new[] + { + $"{ChallengeResourceFilterAttribute.AuthSecretCookieName}={accessKey}" + }; + + httpContext.Request.Headers["Cookie"] = cookie; + + var actionContext = new ActionContext(httpContext, + new RouteData(), + new ActionDescriptor(), + new ModelStateDictionary()); + + var resourceExecutingContext = new ResourceExecutingContext(actionContext, + new List(), + new List()); + + filter.OnResourceExecuting(resourceExecutingContext); + + resourceExecutingContext.Result.Should().BeNull(); + } + + [TestMethod] + public void ExecuteFilter_CorrectSecretValue2_PassesThrough() + { + const string accessKey = "CX"; + + var dic = new Dictionary + { + { "ServiceAccess:Keys:0", "SomeKey" }, // <== NB, not using the first key in the array + { "ServiceAccess:Keys:1", accessKey } + }; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(dic) + .Build(); + + var filter = new ChallengeResourceFilterAttribute(NullLogger.Instance, + configuration); + + var httpContext = new DefaultHttpContext + { + Request = + { + Scheme = "https", + Host = new HostString("localhost"), + Path = "/start" + } + }; + + var cookie = new[] + { + $"{ChallengeResourceFilterAttribute.AuthSecretCookieName}={accessKey}" + }; + + httpContext.Request.Headers["Cookie"] = cookie; + + var actionContext = new ActionContext(httpContext, + new RouteData(), + new ActionDescriptor(), + new ModelStateDictionary()); + + var resourceExecutingContext = new ResourceExecutingContext(actionContext, + new List(), + new List()); + + filter.OnResourceExecuting(resourceExecutingContext); + + resourceExecutingContext.Result.Should().BeNull(); + } + + [TestMethod] + public void ExecuteFilter_IncorrectSecretValue_RedirectsToChallenge() + { + const string accessKey = "CX"; + + var dic = new Dictionary + { + { "ServiceAccess:Keys:0", accessKey } + }; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(dic) + .Build(); + + var filter = new ChallengeResourceFilterAttribute(NullLogger.Instance, + configuration); + + var httpContext = new DefaultHttpContext + { + Request = + { + Scheme = "https", + Host = new HostString("localhost"), + Path = "/start" + } + }; + + var cookie = new[] + { + $"{ChallengeResourceFilterAttribute.AuthSecretCookieName}=not-{accessKey}" + }; + + httpContext.Request.Headers["Cookie"] = cookie; + + var actionContext = new ActionContext(httpContext, + new RouteData(), + new ActionDescriptor(), + new ModelStateDictionary()); + + var resourceExecutingContext = new ResourceExecutingContext(actionContext, + new List(), + new List()); + + filter.OnResourceExecuting(resourceExecutingContext); + + var result = resourceExecutingContext.Result; + + result.Should().BeAssignableTo(); + + var redirect = (RedirectToActionResult)result!; + + redirect.ActionName.Should().Be("Index"); + redirect.ControllerName.Should().Be("Challenge"); + redirect.RouteValues.Should().ContainKey("redirectAddress"); + redirect.Permanent.Should().BeFalse(); + redirect.PreserveMethod.Should().BeFalse(); + } + + [TestMethod] + public void ExecuteFilter_IncorrectSecretValue_LogsWarning() + { + const string accessKey = "CX"; + + var dic = new Dictionary + { + { "ServiceAccess:Keys:0", accessKey } + }; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(dic) + .Build(); + + var logger = new Mock>(); + + var filter = new ChallengeResourceFilterAttribute(logger.Object, configuration); + + var httpContext = new DefaultHttpContext + { + Request = + { + Scheme = "https", + Host = new HostString("localhost"), + Path = "/start" + } + }; + + var cookie = new[] + { + $"{ChallengeResourceFilterAttribute.AuthSecretCookieName}=not-{accessKey}" + }; + + httpContext.Request.Headers["Cookie"] = cookie; + + var actionContext = new ActionContext(httpContext, + new RouteData(), + new ActionDescriptor(), + new ModelStateDictionary()); + + var resourceExecutingContext = new ResourceExecutingContext(actionContext, + new List(), + new List()); + + filter.OnResourceExecuting(resourceExecutingContext); + + logger.VerifyWarning($"Access denied by {nameof(ChallengeResourceFilterAttribute)} (incorrect value submitted)"); + } + + [TestMethod] + public void Filter_NoSecretsConfigured_RedirectsToError() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); + + var logger = new Mock>(); + + var filter = new ChallengeResourceFilterAttribute(logger.Object, configuration); + + + var httpContext = new DefaultHttpContext + { + Request = + { + Scheme = "https", + Host = new HostString("localhost"), + Path = "/start" + } + }; + + var actionContext = new ActionContext(httpContext, + new RouteData(), + new ActionDescriptor(), + new ModelStateDictionary()); + + var resourceExecutingContext = new ResourceExecutingContext(actionContext, + new List(), + new List()); + + filter.OnResourceExecuting(resourceExecutingContext); + + var result = resourceExecutingContext.Result; + + result.Should().BeAssignableTo(); + + var redirect = (RedirectToActionResult)result!; + + redirect.ActionName.Should().Be("Index"); + redirect.ControllerName.Should().Be("Error"); + } + + [TestMethod] + public void Filter_OnlyEmptySecretsConfigured_RedirectsToError() + { + var dic = new Dictionary + { + { "ServiceAccess:Keys:0", " " }, + { "ServiceAccess:Keys:1", "\t" } + }; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(dic) + .Build(); + + var logger = new Mock>(); + + var filter = new ChallengeResourceFilterAttribute(logger.Object, configuration); + + + var httpContext = new DefaultHttpContext + { + Request = + { + Scheme = "https", + Host = new HostString("localhost"), + Path = "/start" + } + }; + + var actionContext = new ActionContext(httpContext, + new RouteData(), + new ActionDescriptor(), + new ModelStateDictionary()); + + var resourceExecutingContext = new ResourceExecutingContext(actionContext, + new List(), + new List()); + + filter.OnResourceExecuting(resourceExecutingContext); + + var result = resourceExecutingContext.Result; + + result.Should().BeAssignableTo(); + + var redirect = (RedirectToActionResult)result!; + + redirect.ActionName.Should().Be("Index"); + redirect.ControllerName.Should().Be("Error"); + } + + [TestMethod] + public void Filter_NoSecretsConfigured_LogsError() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); + + var mockLogger = new Mock>(); + + var filter = new ChallengeResourceFilterAttribute(mockLogger.Object, configuration); + + var httpContext = new DefaultHttpContext + { + Request = + { + Scheme = "https", + Host = new HostString("localhost"), + Path = "/start" + } + }; + + var actionContext = new ActionContext(httpContext, + new RouteData(), + new ActionDescriptor(), + new ModelStateDictionary()); + + var resourceExecutingContext = new ResourceExecutingContext(actionContext, + new List(), + new List()); + + filter.OnResourceExecuting(resourceExecutingContext); + + mockLogger.VerifyError("Service access keys not configured"); + } + + [TestMethod] + public void Filter_OnlyEmptySecretsConfigured_LogsError() + { + var dic = new Dictionary + { + { "ServiceAccess:Keys:0", " " }, + { "ServiceAccess:Keys:1", "\t" } + }; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(dic) + .Build(); + + var mockLogger = new Mock>(); + + var filter = new ChallengeResourceFilterAttribute(mockLogger.Object, configuration); + + var httpContext = new DefaultHttpContext + { + Request = + { + Scheme = "https", + Host = new HostString("localhost"), + Path = "/start" + } + }; + + var actionContext = new ActionContext(httpContext, + new RouteData(), + new ActionDescriptor(), + new ModelStateDictionary()); + + var resourceExecutingContext = new ResourceExecutingContext(actionContext, + new List(), + new List()); + + filter.OnResourceExecuting(resourceExecutingContext); + + mockLogger.VerifyError("Service access keys not configured"); + } + + [TestMethod] + public void ExecuteFilter_AllowPublicAccess_PassesThrough() + { + var dic = new Dictionary + { + { "ServiceAccess:IsPublic", true.ToString() } + }; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(dic) + .Build(); + + var filter = new ChallengeResourceFilterAttribute(NullLogger.Instance, + configuration); + + var httpContext = new DefaultHttpContext + { + Request = + { + Scheme = "https", + Host = new HostString("localhost"), + Path = "/start" + } + }; + + var actionContext = new ActionContext(httpContext, + new RouteData(), + new ActionDescriptor(), + new ModelStateDictionary()); + + var resourceExecutingContext = new ResourceExecutingContext(actionContext, + new List(), + new List()); + + filter.OnResourceExecuting(resourceExecutingContext); + + var result = resourceExecutingContext.Result; + + result.Should().BeNull(); + } +} \ No newline at end of file