From 17589146853cf3f1e749445edbd31e84fa1433da Mon Sep 17 00:00:00 2001 From: Allison Lee Date: Sun, 4 Oct 2026 09:36:48 -0400 Subject: [PATCH] Member profile: Added tests and mock for member profile page to test opt out feature --- .../MemberProfile.page.test.tsx | 23 +++ .../member-profile/MyProfile.page.test.tsx | 160 ++++++++++++++++++ .../member-profile/api/member-profile.mock.ts | 49 ++++++ js/src/lib/test/server.ts | 2 + .../patchats/api/member/dto/MemberDto.java | 1 + .../api/member/MemberControllerTest.java | 101 +++++++++++ .../api/member/MemberServiceTest.java | 64 +++++++ .../member/db/repos/MemberSqlRepoTest.java | 33 ++++ .../auth/security/SecurityWiringTest.java | 93 +++++++++- 9 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 js/src/features/member-profile/MemberProfile.page.test.tsx create mode 100644 js/src/features/member-profile/MyProfile.page.test.tsx create mode 100644 js/src/features/member-profile/api/member-profile.mock.ts diff --git a/js/src/features/member-profile/MemberProfile.page.test.tsx b/js/src/features/member-profile/MemberProfile.page.test.tsx new file mode 100644 index 00000000..2f257567 --- /dev/null +++ b/js/src/features/member-profile/MemberProfile.page.test.tsx @@ -0,0 +1,23 @@ +import { MemberProfilePage } from "@/features/member-profile/MemberProfile.page"; +import { renderWithProviders, screen } from "@/lib/test/render"; +import { Route, Routes } from "react-router-dom"; +import { expect, test } from "vitest"; + +// The self-service endpoint always acts on the signed-in user, so a toggle here would +// let an admin deactivate their own account while looking at someone else's profile. +test("does not offer the self-service opt-out when viewing a member's profile", async () => { + renderWithProviders( + + } /> + , + { route: "/admin/members/b3a1c2d4-0000-4000-8000-000000000002" }, + ); + + expect(await screen.findByDisplayValue("Ann")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Deactivate" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Reactivate" }), + ).not.toBeInTheDocument(); +}); diff --git a/js/src/features/member-profile/MyProfile.page.test.tsx b/js/src/features/member-profile/MyProfile.page.test.tsx new file mode 100644 index 00000000..b1a1f04b --- /dev/null +++ b/js/src/features/member-profile/MyProfile.page.test.tsx @@ -0,0 +1,160 @@ +import { memberSession, sessionResponse } from "@/features/auth/api/auth.mock"; +import { + memberProfile, + memberProfileResponse, +} from "@/features/member-profile/api/member-profile.mock"; +import { MyProfilePage } from "@/features/member-profile/MyProfile.page"; +import { MemberProfile } from "@/features/member-profile/types"; +import { + renderWithProviders, + screen, + waitFor, + within, +} from "@/lib/test/render"; +import { server } from "@/lib/test/server"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { beforeEach, expect, test } from "vitest"; + +const REASON_LABEL = "Why do you want to opt out of PatChats? (optional)"; + +beforeEach(() => { + server.use(http.get("/api/session", () => sessionResponse(memberSession))); +}); + +function respondWithProfile(overrides: Partial) { + server.use( + http.get("/api/members/:id", () => + memberProfileResponse({ ...memberProfile, ...overrides }), + ), + ); +} + +/** Records every body sent to the self-service status endpoint. */ +function captureStatusRequests() { + const bodies: unknown[] = []; + server.use( + http.patch("/api/members/me/status", async ({ request }) => { + const body = (await request.json()) as { active: boolean }; + bodies.push(body); + return HttpResponse.json({ + success: true, + message: "ok", + payload: { ...memberProfile, active: body.active }, + }); + }), + ); + return bodies; +} + +test("offers an active member the option to deactivate their own profile", async () => { + renderWithProviders(); + + expect( + await screen.findByRole("button", { name: "Deactivate" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Reactivate" }), + ).not.toBeInTheDocument(); +}); + +test("deactivating asks for an optional reason in a confirm modal", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(await screen.findByRole("button", { name: "Deactivate" })); + + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText("Deactivate member")).toBeInTheDocument(); + expect(within(dialog).getByLabelText(REASON_LABEL)).toBeInTheDocument(); +}); + +test("confirming deactivation sends the typed reason and flips the button to reactivate", async () => { + const user = userEvent.setup(); + const bodies = captureStatusRequests(); + renderWithProviders(); + + await user.click(await screen.findByRole("button", { name: "Deactivate" })); + const dialog = await screen.findByRole("dialog"); + await user.type(within(dialog).getByLabelText(REASON_LABEL), "Moving abroad"); + await user.click(within(dialog).getByRole("button", { name: "Deactivate" })); + + await waitFor(() => + expect(bodies).toEqual([ + { active: false, deactivationReason: "Moving abroad" }, + ]), + ); + expect( + await screen.findByRole("button", { name: "Reactivate" }), + ).toBeInTheDocument(); + expect( + await screen.findByText("Your profile is now inactive."), + ).toBeInTheDocument(); +}); + +test("deactivating without typing a reason sends no reason at all", async () => { + const user = userEvent.setup(); + const bodies = captureStatusRequests(); + renderWithProviders(); + + await user.click(await screen.findByRole("button", { name: "Deactivate" })); + const dialog = await screen.findByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "Deactivate" })); + + await waitFor(() => expect(bodies).toEqual([{ active: false }])); +}); + +test("reactivating skips the reason prompt, sends only the status, and flips the button back", async () => { + const user = userEvent.setup(); + const bodies = captureStatusRequests(); + respondWithProfile({ active: false, deactivationReason: "Moving abroad" }); + renderWithProviders(); + + await user.click(await screen.findByRole("button", { name: "Reactivate" })); + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText("Reactivate member")).toBeInTheDocument(); + expect(within(dialog).queryByLabelText(REASON_LABEL)).not.toBeInTheDocument(); + await user.click(within(dialog).getByRole("button", { name: "Reactivate" })); + + await waitFor(() => expect(bodies).toEqual([{ active: true }])); + expect( + await screen.findByRole("button", { name: "Deactivate" }), + ).toBeInTheDocument(); + expect( + await screen.findByText("Your profile is now active."), + ).toBeInTheDocument(); +}); + +test("cancelling the confirm modal makes no request", async () => { + const user = userEvent.setup(); + const bodies = captureStatusRequests(); + renderWithProviders(); + + await user.click(await screen.findByRole("button", { name: "Deactivate" })); + const dialog = await screen.findByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); + expect(bodies).toHaveLength(0); +}); + +test("a failed update shows an error and leaves the profile active", async () => { + const user = userEvent.setup(); + server.use( + http.patch("/api/members/me/status", () => + HttpResponse.json({ success: false, message: "boom" }, { status: 500 }), + ), + ); + renderWithProviders(); + + await user.click(await screen.findByRole("button", { name: "Deactivate" })); + const dialog = await screen.findByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "Deactivate" })); + + expect(await screen.findByText("Update failed")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Reactivate" }), + ).not.toBeInTheDocument(); +}); diff --git a/js/src/features/member-profile/api/member-profile.mock.ts b/js/src/features/member-profile/api/member-profile.mock.ts new file mode 100644 index 00000000..b6072678 --- /dev/null +++ b/js/src/features/member-profile/api/member-profile.mock.ts @@ -0,0 +1,49 @@ +import { memberSession } from "@/features/auth/api/auth.mock"; +import { MemberProfile } from "@/features/member-profile/types"; +import { http, HttpResponse } from "msw"; + +/** The signed-in member's own profile (same id as `memberSession`). */ +export const memberProfile: MemberProfile = { + id: memberSession.id, + active: true, + firstName: "Ann", + lastName: "Example", + email: "ann@example.com", + linkedInUrl: "https://www.linkedin.com/in/ann-example", + introduction: "Ann is a designer who loves mentoring.", + referralSource: "Patina Network", + matchPref: "Peer", + industryPref: "Technology", + rolePref: "Design", + topics: "Community", + extraNotes: "", + createdAt: "2026-01-15T14:30:00Z", + updatedAt: "2026-01-15T14:30:00Z", +}; + +export const memberProfileResponse = (profile: MemberProfile) => + HttpResponse.json({ + success: true, + message: "Member retrieved successfully", + payload: profile, + }); + +export const memberProfileHandlers = [ + http.get("/api/members/:id", ({ params }) => + memberProfileResponse({ ...memberProfile, id: String(params.id) }), + ), + http.patch("/api/members/me/status", async ({ request }) => { + const { active, deactivationReason } = (await request.json()) as { + active: boolean; + deactivationReason?: string; + }; + return HttpResponse.json({ + success: true, + message: + active ? + "Membership reactivated successfully" + : "Membership deactivated successfully", + payload: { ...memberProfile, active, deactivationReason }, + }); + }), +]; diff --git a/js/src/lib/test/server.ts b/js/src/lib/test/server.ts index 36c67967..cd2858cd 100644 --- a/js/src/lib/test/server.ts +++ b/js/src/lib/test/server.ts @@ -1,4 +1,5 @@ import { authHandlers } from "@/features/auth/api/auth.mock"; +import { memberProfileHandlers } from "@/features/member-profile/api/member-profile.mock"; import { membersHandlers } from "@/features/members/api/members.mock"; import { sampleHandlers } from "@/features/sample/api/sample.mock"; import { setupServer } from "msw/node"; @@ -10,5 +11,6 @@ import { setupServer } from "msw/node"; export const server = setupServer( ...sampleHandlers, ...authHandlers, + ...memberProfileHandlers, ...membersHandlers, ); diff --git a/src/main/java/org/patinanetwork/patchats/api/member/dto/MemberDto.java b/src/main/java/org/patinanetwork/patchats/api/member/dto/MemberDto.java index dd4a47e0..72761a98 100644 --- a/src/main/java/org/patinanetwork/patchats/api/member/dto/MemberDto.java +++ b/src/main/java/org/patinanetwork/patchats/api/member/dto/MemberDto.java @@ -79,6 +79,7 @@ public static MemberDto from(final Member member) { .rolePref(member.getRolePref()) .topics(member.getTopics()) .extraNotes(member.getExtraNotes()) + .deactivationReason(member.getDeactivationReason()) .createdAt(member.getCreatedAt()) .updatedAt(member.getUpdatedAt()) .build(); diff --git a/src/test/java/org/patinanetwork/patchats/api/member/MemberControllerTest.java b/src/test/java/org/patinanetwork/patchats/api/member/MemberControllerTest.java index 9bf1434f..77776da4 100644 --- a/src/test/java/org/patinanetwork/patchats/api/member/MemberControllerTest.java +++ b/src/test/java/org/patinanetwork/patchats/api/member/MemberControllerTest.java @@ -2,6 +2,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -15,6 +16,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -23,6 +25,7 @@ import org.patinanetwork.patchats.api.member.dto.MemberDto; import org.patinanetwork.patchats.api.member.dto.UpdateMemberRequest; import org.patinanetwork.patchats.api.member.dto.UpdateMemberStatusRequest; +import org.patinanetwork.patchats.auth.security.AuthenticatedMember; import org.patinanetwork.patchats.common.web.ApiExceptionHandler; import org.patinanetwork.patchats.common.web.exception.MemberDuplicateException; import org.patinanetwork.patchats.common.web.exception.MemberNotFoundException; @@ -32,6 +35,9 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -46,6 +52,8 @@ class MemberControllerTest { @MockitoBean private MemberService memberService; + private static final UUID SIGNED_IN_MEMBER_ID = UUID.fromString("6f9a4f4e-0000-4000-8000-000000000001"); + private final ObjectMapper objectMapper = new ObjectMapper(); @BeforeEach @@ -53,6 +61,19 @@ void setUp() { objectMapper.registerModule(new com.fasterxml.jackson.datatype.jdk8.Jdk8Module()); } + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + // Filters are off in this slice, so put the principal where @AuthenticationPrincipal reads it. + private static void signInAs(final UUID memberId) { + final AuthenticatedMember principal = new AuthenticatedMember(memberId, "ann@example.com"); + SecurityContextHolder.getContext() + .setAuthentication(new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")))); + } + @Test void createMember_returnsOkAndMemberDto() throws Exception { final CreateMemberRequest request = MemberTestFixtures.CREATE_REQUEST_ALL_FIELDS; @@ -410,6 +431,86 @@ void updateMemberStatus_badRequestWhenInvalidUuid() throws Exception { .andExpect(status().isBadRequest()); } + @Test + void updateOwnMemberStatus_deactivatesTheSignedInMemberWithTheReason() throws Exception { + signInAs(SIGNED_IN_MEMBER_ID); + when(memberService.updateMemberStatus( + new UpdateMemberStatusRequest(false, Optional.of("Moving abroad")), SIGNED_IN_MEMBER_ID)) + .thenReturn(MemberDto.builder() + .id(SIGNED_IN_MEMBER_ID) + .active(false) + .deactivationReason("Moving abroad") + .build()); + + // Raw JSON rather than a serialized record, so this pins the field's name on the wire. + mockMvc.perform(patch("/api/members/me/status") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"active\":false,\"deactivationReason\":\"Moving abroad\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Membership deactivated successfully")) + .andExpect(jsonPath("$.payload.active").value(false)) + .andExpect(jsonPath("$.payload.deactivationReason").value("Moving abroad")); + } + + @Test + void updateOwnMemberStatus_reactivatesTheSignedInMember() throws Exception { + signInAs(SIGNED_IN_MEMBER_ID); + when(memberService.updateMemberStatus( + new UpdateMemberStatusRequest(true, Optional.empty()), SIGNED_IN_MEMBER_ID)) + .thenReturn( + MemberDto.builder().id(SIGNED_IN_MEMBER_ID).active(true).build()); + + mockMvc.perform(patch("/api/members/me/status") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"active\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Membership reactivated successfully")) + .andExpect(jsonPath("$.payload.active").value(true)); + } + + @Test + void updateOwnMemberStatus_ignoresAnyMemberIdInTheBody() throws Exception { + final UUID someoneElse = UUID.randomUUID(); + signInAs(SIGNED_IN_MEMBER_ID); + when(memberService.updateMemberStatus(any(UpdateMemberStatusRequest.class), eq(SIGNED_IN_MEMBER_ID))) + .thenReturn(MemberDto.builder() + .id(SIGNED_IN_MEMBER_ID) + .active(false) + .build()); + + mockMvc.perform(patch("/api/members/me/status") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"active\":false,\"id\":\"" + someoneElse + "\"}")) + .andExpect(status().isOk()); + + verify(memberService, never()).updateMemberStatus(any(UpdateMemberStatusRequest.class), eq(someoneElse)); + } + + @Test + void updateOwnMemberStatus_badRequestWhenActiveMissing() throws Exception { + signInAs(SIGNED_IN_MEMBER_ID); + + mockMvc.perform(patch("/api/members/me/status") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + void updateOwnMemberStatus_notFoundWhenTheMemberNoLongerExists() throws Exception { + signInAs(SIGNED_IN_MEMBER_ID); + when(memberService.updateMemberStatus(any(UpdateMemberStatusRequest.class), eq(SIGNED_IN_MEMBER_ID))) + .thenThrow(new MemberNotFoundException(SIGNED_IN_MEMBER_ID)); + + mockMvc.perform(patch("/api/members/me/status") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"active\":false}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)); + } + @Test void getMembersReturnsAllMembers() throws Exception { final MemberDto firstMember = MemberDto.builder() diff --git a/src/test/java/org/patinanetwork/patchats/api/member/MemberServiceTest.java b/src/test/java/org/patinanetwork/patchats/api/member/MemberServiceTest.java index 997dbcb3..c701cc6a 100644 --- a/src/test/java/org/patinanetwork/patchats/api/member/MemberServiceTest.java +++ b/src/test/java/org/patinanetwork/patchats/api/member/MemberServiceTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -507,6 +508,69 @@ void updateMemberStatus_throwsWhenMemberNotFound() { verify(memberRepo, never()).updateMember(any()); } + @Test + void createMember_startsWithNoDeactivationReason() { + final ArgumentCaptor captor = ArgumentCaptor.forClass(Member.class); + when(memberRepo.getMemberByEmail(any())).thenReturn(Optional.empty()); + when(memberRepo.createMember(any(Member.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + final MemberDto response = memberService.createMember(MemberTestFixtures.CREATE_REQUEST_ALL_FIELDS); + + verify(memberRepo).createMember(captor.capture()); + assertNull(captor.getValue().getDeactivationReason()); + assertNull(response.getDeactivationReason()); + } + + @Test + void updateMemberStatus_deactivatingStoresTheReason() { + final UUID id = UUID.randomUUID(); + final Member existingMember = Member.builder().id(id).active(true).build(); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Member.class); + when(memberRepo.getMemberById(id)).thenReturn(Optional.of(existingMember)); + when(memberRepo.updateMember(any(Member.class))).thenReturn(Optional.of(existingMember)); + + final MemberDto response = + memberService.updateMemberStatus(new UpdateMemberStatusRequest(false, Optional.of("Moving")), id); + + verify(memberRepo).updateMember(captor.capture()); + assertEquals("Moving", captor.getValue().getDeactivationReason()); + assertEquals("Moving", response.getDeactivationReason()); + } + + @Test + void updateMemberStatus_deactivatingWithoutAReasonStoresNull() { + final UUID id = UUID.randomUUID(); + final Member existingMember = Member.builder().id(id).active(true).build(); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Member.class); + when(memberRepo.getMemberById(id)).thenReturn(Optional.of(existingMember)); + when(memberRepo.updateMember(any(Member.class))).thenReturn(Optional.of(existingMember)); + + memberService.updateMemberStatus(new UpdateMemberStatusRequest(false, Optional.empty()), id); + + verify(memberRepo).updateMember(captor.capture()); + assertEquals(false, captor.getValue().isActive()); + assertNull(captor.getValue().getDeactivationReason()); + } + + @Test + void updateMemberStatus_reactivatingClearsThePreviousReasonEvenIfOneIsSent() { + final UUID id = UUID.randomUUID(); + final Member existingMember = Member.builder() + .id(id) + .active(false) + .deactivationReason("Moving") + .build(); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Member.class); + when(memberRepo.getMemberById(id)).thenReturn(Optional.of(existingMember)); + when(memberRepo.updateMember(any(Member.class))).thenReturn(Optional.of(existingMember)); + + memberService.updateMemberStatus(new UpdateMemberStatusRequest(true, Optional.of("Ignored")), id); + + verify(memberRepo).updateMember(captor.capture()); + assertTrue(captor.getValue().isActive()); + assertNull(captor.getValue().getDeactivationReason()); + } + private static MemberFilterCriteria emptyCriteria() { return new MemberFilterCriteria( Optional.empty(), diff --git a/src/test/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepoTest.java b/src/test/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepoTest.java index 1cbdb6f2..b6d38c87 100644 --- a/src/test/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepoTest.java +++ b/src/test/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepoTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; @@ -152,6 +153,37 @@ void updateMember_updatesExactlyOneField() { assertMemberFields(result, updatedMember); } + @Test + void createMember_startsWithNoDeactivationReason() { + assertNull(member.getDeactivationReason()); + } + + @Test + void updateMember_persistsTheDeactivationReason() { + member.setActive(false); + member.setDeactivationReason("Moving abroad"); + + final Member result = memberRepo.updateMember(member).orElseThrow(); + + assertEquals("Moving abroad", result.getDeactivationReason()); + assertEquals( + "Moving abroad", + memberRepo.getMemberById(member.getId()).orElseThrow().getDeactivationReason()); + } + + @Test + void updateMember_clearsTheDeactivationReason() { + member.setDeactivationReason("Moving abroad"); + memberRepo.updateMember(member).orElseThrow(); + + member.setActive(true); + member.setDeactivationReason(null); + final Member result = memberRepo.updateMember(member).orElseThrow(); + + assertNull(result.getDeactivationReason()); + assertNull(memberRepo.getMemberById(member.getId()).orElseThrow().getDeactivationReason()); + } + @Test void updateMember_returnsEmptyWhenMemberDoesNotExist() { final Member missingMember = Member.builder() @@ -209,5 +241,6 @@ private static void assertMemberFields(final Member actual, final Member expecte assertEquals(expected.getRolePref(), actual.getRolePref()); assertEquals(expected.getTopics(), actual.getTopics()); assertEquals(expected.getExtraNotes(), actual.getExtraNotes()); + assertEquals(expected.getDeactivationReason(), actual.getDeactivationReason()); } } diff --git a/src/test/java/org/patinanetwork/patchats/auth/security/SecurityWiringTest.java b/src/test/java/org/patinanetwork/patchats/auth/security/SecurityWiringTest.java index 30f8e249..3fa8f0ed 100644 --- a/src/test/java/org/patinanetwork/patchats/auth/security/SecurityWiringTest.java +++ b/src/test/java/org/patinanetwork/patchats/auth/security/SecurityWiringTest.java @@ -3,6 +3,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -20,6 +22,7 @@ import org.patinanetwork.patchats.api.member.db.models.Member; import org.patinanetwork.patchats.api.member.db.repos.MemberRepo; import org.patinanetwork.patchats.api.member.dto.MemberDto; +import org.patinanetwork.patchats.api.member.dto.UpdateMemberStatusRequest; import org.patinanetwork.patchats.auth.AuthController; import org.patinanetwork.patchats.auth.AuthService; import org.patinanetwork.patchats.auth.repo.AdminRepo; @@ -226,14 +229,98 @@ void adminEndpointsAdmitAMemberOnTheAllowlist() throws Exception { .andExpect(status().isOk()); } - /** Completes a magic-link verification and returns the session it established. */ - private MockHttpSession signIn(final boolean isAdmin) throws Exception { - final Member member = Member.builder() + @Test + void aMemberCanChangeTheirOwnStatusWithoutBeingAnAdmin() throws Exception { + final Member member = aMember(); + final MockHttpSession session = signIn(member, false); + when(memberService.updateMemberStatus( + new UpdateMemberStatusRequest(false, Optional.of("Moving abroad")), member.getId())) + .thenReturn(MemberDto.builder() + .id(member.getId()) + .active(false) + .deactivationReason("Moving abroad") + .build()); + + mockMvc.perform(patch("/api/members/me/status") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"active\":false,\"deactivationReason\":\"Moving abroad\"}") + .session(session) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.payload.active").value(false)) + .andExpect(jsonPath("$.payload.deactivationReason").value("Moving abroad")); + } + + @Test + void ownStatusEndpointRejectsAnonymousCallers() throws Exception { + mockMvc.perform(patch("/api/members/me/status") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"active\":false}") + .with(csrf())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)); + + verify(memberService, never()).updateMemberStatus(any(UpdateMemberStatusRequest.class), any(UUID.class)); + } + + @Test + void ownStatusEndpointRequiresACsrfTokenEvenWhenSignedIn() throws Exception { + final MockHttpSession session = signIn(false); + + mockMvc.perform(patch("/api/members/me/status") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"active\":false}") + .session(session)) + .andExpect(status().isForbidden()); + + verify(memberService, never()).updateMemberStatus(any(UpdateMemberStatusRequest.class), any(UUID.class)); + } + + @Test + void aMemberCannotUseTheAdminStatusEndpoint() throws Exception { + final MockHttpSession session = signIn(false); + + mockMvc.perform(patch("/api/members/admin/{id}/status", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"active\":false}") + .session(session) + .with(csrf())) + .andExpect(status().isForbidden()); + + verify(memberService, never()).updateMemberStatus(any(UpdateMemberStatusRequest.class), any(UUID.class)); + } + + @Test + void anAdminCanChangeAnotherMembersStatus() throws Exception { + final MockHttpSession session = signIn(true); + final UUID someoneElse = UUID.randomUUID(); + when(memberService.updateMemberStatus(new UpdateMemberStatusRequest(false, Optional.empty()), someoneElse)) + .thenReturn(MemberDto.builder().id(someoneElse).active(false).build()); + + mockMvc.perform(patch("/api/members/admin/{id}/status", someoneElse) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"active\":false}") + .session(session) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.payload.active").value(false)); + } + + private static Member aMember() { + return Member.builder() .id(UUID.randomUUID()) .email("ann@example.com") .firstName("Ann") .lastName("Example") .build(); + } + + /** Completes a magic-link verification and returns the session it established. */ + private MockHttpSession signIn(final boolean isAdmin) throws Exception { + return signIn(aMember(), isAdmin); + } + + private MockHttpSession signIn(final Member member, final boolean isAdmin) throws Exception { when(authService.verify("raw-token")).thenReturn(member); when(admins.isAdmin(member.getEmail())).thenReturn(isAdmin);