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 0000000..2f25756
--- /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 0000000..b1a1f04
--- /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 0000000..b607267
--- /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 36c6796..cd2858c 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 dd4a47e..72761a9 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 9bf1434..77776da 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 997dbcb..c701cc6 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 1cbdb6f..b6d38c8 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 30f8e24..3fa8f0e 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);