diff --git a/backend/LexBoxApi/Controllers/AuthTestingController.cs b/backend/LexBoxApi/Controllers/AuthTestingController.cs index 3dbeae2ce..028f19dc0 100644 --- a/backend/LexBoxApi/Controllers/AuthTestingController.cs +++ b/backend/LexBoxApi/Controllers/AuthTestingController.cs @@ -1,5 +1,6 @@ using LexBoxApi.Auth; using LexCore.Auth; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace LexBoxApi.Controllers; @@ -27,4 +28,11 @@ public OkResult RequiresForgotPasswordAudience() { return Ok(); } + + [HttpGet("403")] + [AllowAnonymous] + public ForbidResult Forbidden() + { + return Forbid(); + } } diff --git a/backend/Testing/Browser/Base/PageTest.cs b/backend/Testing/Browser/Base/PageTest.cs index 846a5ad20..9c6d71fa8 100644 --- a/backend/Testing/Browser/Base/PageTest.cs +++ b/backend/Testing/Browser/Base/PageTest.cs @@ -66,6 +66,13 @@ await Context.Tracing.StartAsync(new() { DeferredExceptions.Add(new UnexpectedResponseException(response)); } + else if (response.Request.IsNavigationRequest && response.Status >= (int)HttpStatusCode.BadRequest) + { + // 400s are client errors that our tests shouldn't trigger under normal circumstances. + // And if they're navigation requests SvelteKit/our UI might never see them (e.g. /api/*) + // i.e. they won't be handled well i.e. we don't like them. + DeferredExceptions.Add(new UnexpectedResponseException(response)); + } }; } @@ -102,18 +109,22 @@ public async Task LoginAs(string user, string password) .ShouldContainKey("Set-Cookie"); var cookies = responseMessage.Headers.GetValues("Set-Cookie").ToArray(); cookies.ShouldNotBeEmpty(); + await SetCookies(cookies); + } + + protected async Task SetCookies(string[] cookies) + { var cookieContainer = new CookieContainer(); foreach (var cookie in cookies) { cookieContainer.SetCookies(new($"{TestingEnvironmentVariables.ServerBaseUrl}"), cookie); } - await Context.AddCookiesAsync(cookieContainer.GetAllCookies() .Select(cookie => new Microsoft.Playwright.Cookie { Value = cookie.Value, Domain = cookie.Domain, - Expires = (float)cookie.Expires.Subtract(DateTime.UnixEpoch).TotalSeconds, + Expires = cookie.Expires == new DateTime() ? null : (float)cookie.Expires.Subtract(DateTime.UnixEpoch).TotalSeconds, Name = cookie.Name, Path = cookie.Path, Secure = cookie.Secure, diff --git a/backend/Testing/Browser/Page/AdminDashboardPage.cs b/backend/Testing/Browser/Page/AdminDashboardPage.cs index 437715ce3..82e28e04c 100644 --- a/backend/Testing/Browser/Page/AdminDashboardPage.cs +++ b/backend/Testing/Browser/Page/AdminDashboardPage.cs @@ -11,8 +11,13 @@ public AdminDashboardPage(IPage page) public async Task OpenProject(string projectName, string projectCode) { - var projectTable = Page.Locator("table").Nth(0); - await projectTable.GetByRole(AriaRole.Link, new() { Name = projectName, Exact = true}).ClickAsync(); + await ClickProject(projectName); return await new ProjectPage(Page, projectName, projectCode).WaitFor(); } + + public async Task ClickProject(string projectName) + { + var projectTable = Page.Locator("table").Nth(0); + await projectTable.GetByRole(AriaRole.Link, new() { Name = projectName, Exact = true }).ClickAsync(); + } } diff --git a/backend/Testing/Browser/Page/AuthenticatedBasePage.cs b/backend/Testing/Browser/Page/AuthenticatedBasePage.cs index 5f1411b2d..027f768c6 100644 --- a/backend/Testing/Browser/Page/AuthenticatedBasePage.cs +++ b/backend/Testing/Browser/Page/AuthenticatedBasePage.cs @@ -12,9 +12,8 @@ public AuthenticatedBasePage(IPage page, string url, ILocator testLocator) EmailVerificationAlert = new EmailVerificationAlert(Page); } - public async Task GoHome() + public async Task GoHome() { await Page.Locator(".breadcrumbs").GetByRole(AriaRole.Link, new() { Name = "Home" }).ClickAsync(); - return await new UserDashboardPage(Page).WaitFor(); } } diff --git a/backend/Testing/Browser/Page/BasePage.cs b/backend/Testing/Browser/Page/BasePage.cs index 99dbd6308..d1d802172 100644 --- a/backend/Testing/Browser/Page/BasePage.cs +++ b/backend/Testing/Browser/Page/BasePage.cs @@ -4,6 +4,8 @@ namespace Testing.Browser.Page; +public record GotoOptions(bool? ExpectRedirect = false); + public abstract class BasePage where T : BasePage { public IPage Page { get; private set; } @@ -22,7 +24,7 @@ public BasePage(IPage page, string? url, ILocator[] testLocators) TestLocators = testLocators; } - public virtual async Task Goto() + public virtual async Task Goto(GotoOptions? options = null) { if (Url is null) { @@ -31,7 +33,13 @@ public virtual async Task Goto() var response = await Page.GotoAsync(Url); response?.Ok.ShouldBeTrue(); // is null if same URL, but different hash - return await WaitFor(); + + if (options?.ExpectRedirect != true) + { + await WaitFor(); + } + + return (T)this; } public async Task WaitFor() diff --git a/backend/Testing/Browser/Page/External/MailDevPages.cs b/backend/Testing/Browser/Page/External/MailDevPages.cs index fcf05bbb6..9cc3e75ed 100644 --- a/backend/Testing/Browser/Page/External/MailDevPages.cs +++ b/backend/Testing/Browser/Page/External/MailDevPages.cs @@ -14,9 +14,9 @@ protected override MailEmailPage GetEmailPage() return new MailDevEmailPage(Page); } - public override async Task Goto() + public override async Task Goto(GotoOptions? options = null) { - await base.Goto(); + await base.Goto(options); await Page.Locator("input.search-input").FillAsync(MailboxId); return this; } diff --git a/backend/Testing/Browser/Page/External/MailPages.cs b/backend/Testing/Browser/Page/External/MailPages.cs index d6b6455cc..bff31cd6a 100644 --- a/backend/Testing/Browser/Page/External/MailPages.cs +++ b/backend/Testing/Browser/Page/External/MailPages.cs @@ -41,6 +41,8 @@ public abstract class MailEmailPage : BasePage { protected readonly ILocator bodyLocator; + public ILocator ResetPasswordButton => bodyLocator.GetByRole(AriaRole.Link, new() { Name = "Reset password" }); + public MailEmailPage(IPage page, string? url, ILocator bodyLocator) : base(page, url, bodyLocator) { this.bodyLocator = bodyLocator; @@ -53,6 +55,6 @@ public Task ClickVerifyEmail() public Task ClickResetPassword() { - return bodyLocator.GetByRole(AriaRole.Link, new() { Name = "Reset password" }).ClickAsync(); + return ResetPasswordButton.ClickAsync(); } } diff --git a/backend/Testing/Browser/Page/External/MailinatorPages.cs b/backend/Testing/Browser/Page/External/MailinatorPages.cs index 94188a3d6..09c47727e 100644 --- a/backend/Testing/Browser/Page/External/MailinatorPages.cs +++ b/backend/Testing/Browser/Page/External/MailinatorPages.cs @@ -15,10 +15,10 @@ protected override MailEmailPage GetEmailPage() return new MailinatorEmailPage(Page); } - public override async Task Goto() + public override async Task Goto(GotoOptions? options = null) { Url = $"https://www.mailinator.com/v4/public/inboxes.jsp?to={MailboxId}"; - return await base.Goto(); + return await base.Goto(options); } } diff --git a/backend/Testing/Browser/Page/SandboxPage.cs b/backend/Testing/Browser/Page/SandboxPage.cs index 049ffd859..d1886a446 100644 --- a/backend/Testing/Browser/Page/SandboxPage.cs +++ b/backend/Testing/Browser/Page/SandboxPage.cs @@ -4,7 +4,7 @@ namespace Testing.Browser.Page; public class SandboxPage : BasePage { - public SandboxPage(IPage page) : base(page, "/sandbox", page.Locator(":text('Sandbox')")) + public SandboxPage(IPage page) : base(page, "/sandbox", page.GetByRole(AriaRole.Heading, new() { Name = "Sandbox" })) { } } diff --git a/backend/Testing/Browser/Page/TempUserDashboardPage.cs b/backend/Testing/Browser/Page/TempUserDashboardPage.cs index b2b13e5f2..8f255493d 100644 --- a/backend/Testing/Browser/Page/TempUserDashboardPage.cs +++ b/backend/Testing/Browser/Page/TempUserDashboardPage.cs @@ -1,5 +1,6 @@ using Microsoft.Playwright; using Testing.Browser.Util; +using Testing.Services; namespace Testing.Browser.Page; @@ -14,6 +15,8 @@ public TempUserDashboardPage(IPage page, TempUser user) : base(page) public async ValueTask DisposeAsync() { - await Page.DeleteUser(User.Id); + var context = await Page.Context.Browser.NewContextAsync(); + await context.APIRequest.LoginAs("admin", TestingEnvironmentVariables.DefaultPassword); + await context.APIRequest.DeleteUser(User.Id); } } diff --git a/backend/Testing/Browser/SandboxPageTests.cs b/backend/Testing/Browser/SandboxPageTests.cs deleted file mode 100644 index bbcf16f13..000000000 --- a/backend/Testing/Browser/SandboxPageTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Microsoft.Playwright; -using Testing.Browser.Base; -using Testing.Browser.Page; - -namespace Testing.Browser; - -[Trait("Category", "Integration")] -public class SandboxPageTests : PageTest -{ - [Fact] - public async Task CatchGoto500InSameTab() - { - - await new SandboxPage(Page).Goto(); - await Page.RunAndWaitForResponseAsync(async () => - { - await Page.GetByText("Goto 500 page").ClickAsync(); - }, "/api/testing/test500NoException"); - ExpectDeferredException(); - } - - [Fact] - public async Task CatchGoto500InNewTab() - { - await new SandboxPage(Page).Goto(); - await Context.RunAndWaitForPageAsync(async () => - { - await Page.GetByText("goto 500 new tab").ClickAsync(); - }); - ExpectDeferredException(); - } - - [Fact] - public async Task CatchFetch500() - { - await new SandboxPage(Page).Goto(); - await Page.RunAndWaitForResponseAsync(async () => - { - await Page.GetByText("Fetch 500").ClickAsync(); - }, "/api/testing/test500NoException"); - ExpectDeferredException(); - await Expect(Page.Locator(".modal-box.bg-error:text-matches('Unexpected response:.*(500)', 'g')")).ToBeVisibleAsync(); - } -} diff --git a/backend/Testing/Browser/StatusCodeTests.cs b/backend/Testing/Browser/StatusCodeTests.cs new file mode 100644 index 000000000..883106b4f --- /dev/null +++ b/backend/Testing/Browser/StatusCodeTests.cs @@ -0,0 +1,187 @@ +using LexBoxApi.Auth; +using Microsoft.Playwright; +using Shouldly; +using Testing.Browser.Base; +using Testing.Browser.Page; +using Testing.Browser.Page.External; +using Testing.Services; + +namespace Testing.Browser; + +[Trait("Category", "Integration")] +public class StatusCodeTests : PageTest +{ + [Fact] + public async Task CatchGoto500InSameTab() + { + await new SandboxPage(Page).Goto(); + await Page.RunAndWaitForResponseAsync(async () => + { + await Page.GetByText("Goto API 500", new() { Exact = true }).ClickAsync(); + }, "/api/testing/test500NoException"); + ExpectDeferredException(); + } + + [Fact] + public async Task CatchGoto500InNewTab() + { + await new SandboxPage(Page).Goto(); + await Context.RunAndWaitForPageAsync(async () => + { + await Page.GetByText("Goto API 500 new tab").ClickAsync(); + }); + ExpectDeferredException(); + } + + [Fact] + public async Task CatchPageLoad500() + { + await new SandboxPage(Page).Goto(); + await Page.GetByText("Goto page load 500").ClickAsync(); + ExpectDeferredException(); + await Expect(Page.Locator(":text-matches('Unexpected response:.*(500)', 'g')").First).ToBeVisibleAsync(); + } + + [Fact] + public async Task PageLoad500InNewTabLandsOnErrorPage() + { + await new SandboxPage(Page).Goto(); + var newPage = await Context.RunAndWaitForPageAsync(async () => + { + await Page.GetByText("Goto page load 500").ClickAsync(new() + { + Modifiers = new[] { KeyboardModifier.Control }, + }); + }); + await Expect(newPage.Locator(":text-matches('Unexpected response:.*(500)', 'g')").First).ToBeVisibleAsync(); + } + + [Fact] + public async Task CatchFetch500AndErrorDialog() + { + await new SandboxPage(Page).Goto(); + await Page.RunAndWaitForResponseAsync(async () => + { + await Page.GetByText("Fetch 500").ClickAsync(); + }, "/api/testing/test500NoException"); + ExpectDeferredException(); + await Expect(Page.Locator(".modal-box.bg-error:text-matches('Unexpected response:.*(500)', 'g')")).ToBeVisibleAsync(); + } + + [Fact] + public async Task NodeSurvivesCorruptJwt() + { + var corruptJwt = "bla-bla-bla"; + await SetCookies(new[] { $"{AuthKernel.AuthCookieName}={corruptJwt}" }); + await new UserDashboardPage(Page).Goto(new() { ExpectRedirect = true }); + await new LoginPage(Page).WaitFor(); + } + + [Fact] + public async Task ServerPageLoad403IsRedirectedToLogin() + { + await SetCookies(new[] { $"{AuthKernel.AuthCookieName}={TestConstants.InvalidJwt}" }); + await new UserDashboardPage(Page).Goto(new() { ExpectRedirect = true }); + await new LoginPage(Page).WaitFor(); + } + + [Fact] + public async Task ClientPageLoad403IsRedirectedToLogin() + { + await LoginAs("admin", TestingEnvironmentVariables.DefaultPassword); + var adminDashboardPage = await new AdminDashboardPage(Page).Goto(); + + await SetCookies(new[] { $"{AuthKernel.AuthCookieName}={TestConstants.InvalidJwt}" }); + + var response = await Page.RunAndWaitForResponseAsync(async () => + { + await adminDashboardPage.ClickProject("Sena 3"); + }, "/api/graphql"); + + response.Status.ShouldBe(401); + await new LoginPage(Page).WaitFor(); + } + + [Fact] + public async Task CatchGoto403InSameTab() + { + + await new SandboxPage(Page).Goto(); + await Page.RunAndWaitForResponseAsync(async () => + { + await Page.GetByText("Goto API 403", new() { Exact = true }).ClickAsync(); + }, "/api/AuthTesting/403"); + ExpectDeferredException(); + } + + [Fact] + public async Task CatchGoto403InNewTab() + { + await new SandboxPage(Page).Goto(); + await Context.RunAndWaitForPageAsync(async () => + { + await Page.GetByText("Goto API 403 new tab").ClickAsync(); + }); + ExpectDeferredException(); + } + + [Fact] + public async Task PageLoad403IsRedirectedToHome() + { + await LoginAs("manager", TestingEnvironmentVariables.DefaultPassword); + await new SandboxPage(Page).Goto(); + await Page.GetByText("Goto page load 403").ClickAsync(); + await new UserDashboardPage(Page).WaitFor(); + } + + [Fact] + public async Task PageLoad403InNewTabIsRedirectedToHome() + { + await LoginAs("manager", TestingEnvironmentVariables.DefaultPassword); + await new SandboxPage(Page).Goto(); + var newPage = await Context.RunAndWaitForPageAsync(async () => + { + await Page.GetByText("Goto page load 403").ClickAsync(new() + { + Modifiers = new[] { KeyboardModifier.Control }, + }); + }); + await new UserDashboardPage(newPage).WaitFor(); + } + + [Fact] + public async Task PageLoad403OnHomePageIsRedirectedToLogin() + { + // (1) Get JWT with only forgot-password audience + // - Register + var mailinatorId = Guid.NewGuid().ToString(); + var email = $"{mailinatorId}@mailinator.com"; + var password = email; + await using var userDashboardPage = await RegisterUser($"Test: {nameof(PageLoad403OnHomePageIsRedirectedToLogin)} - {mailinatorId}", email, password); + + // - Request forgot password email + var loginPage = await Logout(); + var forgotPasswordPage = await loginPage.ClickForgotPassword(); + await forgotPasswordPage.FillForm(email); + await forgotPasswordPage.Submit(); + + // - Get JWT from reset password link + var inboxPage = await MailInboxPage.Get(Page, mailinatorId).Goto(); + var emailPage = await inboxPage.OpenEmail(); + var href = await emailPage.ResetPasswordButton.GetAttributeAsync("href"); + var forgotPasswordJwt = href.Split("jwt=")[1].Split("&")[0]; + + // (2) Get to a non-home page with an empty urql cache + await LoginAs(email, password); + var userAccountPage = await new UserAccountSettingsPage(Page).Goto(); + + // (3) Update cookie with the reset-password audience JWT and try to go home + await SetCookies(new[] { $"{AuthKernel.AuthCookieName}={forgotPasswordJwt}" }); + + var response = await Page.RunAndWaitForResponseAsync(userAccountPage.GoHome, "/api/graphql"); + response.Status.ShouldBe(403); + + // (4) Expect to be redirected to login page + await new LoginPage(Page).WaitFor(); + } +} diff --git a/backend/Testing/Browser/Util/HttpUtils.cs b/backend/Testing/Browser/Util/HttpUtils.cs index 3fc634f84..af0047250 100644 --- a/backend/Testing/Browser/Util/HttpUtils.cs +++ b/backend/Testing/Browser/Util/HttpUtils.cs @@ -8,9 +8,9 @@ namespace Testing.Browser.Util; public static class HttpUtils { - public static async Task ExecuteGql(this IPage page, [StringSyntax("graphql")] string gql, bool expectGqlError = false) + public static async Task ExecuteGql(this IAPIRequestContext requestContext, [StringSyntax("graphql")] string gql, bool expectGqlError = false) { - var response = await page.APIRequest.PostAsync( + var response = await requestContext.PostAsync( $"{TestingEnvironmentVariables.ServerBaseUrl}/api/graphql", new() { DataObject = new { query = gql } }); response.Status.ShouldBe(200, $"code was {response.Status} ({response.StatusText})"); @@ -23,9 +23,9 @@ public static async Task ExecuteGql(this IPage page, [StringSyntax(" return jsonResponse; } - public static Task DeleteUser(this IPage page, Guid userId) + public static Task DeleteUser(this IAPIRequestContext requestContext, Guid userId) { - return page.ExecuteGql($$""" + return requestContext.ExecuteGql($$""" mutation { deleteUserByAdminOrSelf(input: { userId: "{{userId}}" }) { user { @@ -40,5 +40,10 @@ ... on Error { } """); } -} + public static async Task LoginAs(this IAPIRequestContext requestContext, string user, string password) + { + await requestContext.PostAsync($"{TestingEnvironmentVariables.ServerBaseUrl}/api/login", + new() { DataObject = new { password, emailOrUsername = user, preHashedPassword = false } }); + } +} diff --git a/backend/Testing/Services/TestConstants.cs b/backend/Testing/Services/TestConstants.cs new file mode 100644 index 000000000..e7e7d4bbe --- /dev/null +++ b/backend/Testing/Services/TestConstants.cs @@ -0,0 +1,7 @@ +namespace Testing.Services; + +public class TestConstants +{ + // Non LexBox JWT: https://www.javainuse.com/jwtgenerator + public const string InvalidJwt = "eyJhbGciOiJIUzI1NiJ9.eyJSb2xlIjoiQWRtaW4iLCJJc3N1ZXIiOiJJc3N1ZXIiLCJVc2VybmFtZSI6IkphdmFJblVzZSIsImV4cCI6MTY5OTM0ODY2NywiaWF0IjoxNjk5MzQ4NjY3fQ.f8N63gcD_iv-E_x0ERhJwARaBKnZnORaZGe0N2J0VGM"; +} diff --git a/frontend/src/lib/layout/Breadcrumbs.svelte b/frontend/src/lib/layout/Breadcrumbs.svelte index f18d109aa..b7a8cb67f 100644 --- a/frontend/src/lib/layout/Breadcrumbs.svelte +++ b/frontend/src/lib/layout/Breadcrumbs.svelte @@ -47,8 +47,10 @@ if (name) { crumbs.push({ name, href }); } - // If it's not a CrumbConfig, then the loop should be over currConfig = currConfig[token] as CrumbConfig; + if (!currConfig) { + break; + } } } diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts index 4ebe1ded7..fb99d8af3 100644 --- a/frontend/src/lib/user.ts +++ b/frontend/src/lib/user.ts @@ -106,7 +106,12 @@ export function getUser(cookies: Cookies): LexAuthUser | null { return null } - return jwtToUser(jwtDecode(token)); + try { + return jwtToUser(jwtDecode(token)); + } catch (error) { + console.error(error); + return null; + } } function jwtToUser(user: JwtTokenUser): LexAuthUser { diff --git a/frontend/src/routes/(unauthenticated)/sandbox/+page.svelte b/frontend/src/routes/(unauthenticated)/sandbox/+page.svelte index e4f87a48d..4b8248a51 100644 --- a/frontend/src/routes/(unauthenticated)/sandbox/+page.svelte +++ b/frontend/src/routes/(unauthenticated)/sandbox/+page.svelte @@ -9,12 +9,25 @@ async function fetch500(): Promise { return fetch('/api/testing/test500NoException'); } + + async function fetch403(): Promise { + return fetch('/api/AuthTesting/403'); + } +

Sandbox

- Goto 500 page - Goto 500 new tab + Goto page load 403 + Goto API 403 + Goto API 403 new tab + + +
diff --git a/frontend/src/routes/(unauthenticated)/sandbox/[status_code]/+page.svelte b/frontend/src/routes/(unauthenticated)/sandbox/[status_code]/+page.svelte new file mode 100644 index 000000000..3830346a5 --- /dev/null +++ b/frontend/src/routes/(unauthenticated)/sandbox/[status_code]/+page.svelte @@ -0,0 +1,8 @@ + + +

Sandbox - Status code: {data.statusCode}

diff --git a/frontend/src/routes/(unauthenticated)/sandbox/[status_code]/+page.ts b/frontend/src/routes/(unauthenticated)/sandbox/[status_code]/+page.ts new file mode 100644 index 000000000..12b2c6a5d --- /dev/null +++ b/frontend/src/routes/(unauthenticated)/sandbox/[status_code]/+page.ts @@ -0,0 +1,12 @@ +import type { PageLoadEvent } from './$types'; + +export async function load(event: PageLoadEvent) { + const statusCode = Number(event.params?.status_code); + if (statusCode === 403) { + await event.fetch('/api/AuthTesting/403'); + } else if (statusCode === 500) { + await event.fetch('/api/testing/test500NoException'); + } + + return { statusCode }; +}