From ca42d6e59f54c37c490228bfc4a9ae140b8d5db1 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:33:37 -0300 Subject: [PATCH] fix(team-members): cover mid-wizard invite loss and accept empty invite responses --- .../api/team-member-invite-api.ts | 6 +- .../team-members/api/team-members-api.ts | 4 +- .../invite/invite-registration-page.test.tsx | 59 ++++++++++++++++--- .../invite-calls-without-body.test.ts | 21 +++++++ 4 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 src/test/domain/team-members/invite-calls-without-body.test.ts diff --git a/src/domain/team-member-invite/api/team-member-invite-api.ts b/src/domain/team-member-invite/api/team-member-invite-api.ts index 64470e29..b0545c4d 100644 --- a/src/domain/team-member-invite/api/team-member-invite-api.ts +++ b/src/domain/team-member-invite/api/team-member-invite-api.ts @@ -4,7 +4,7 @@ */ import { isHTTPError } from "ky"; -import { apiPost } from "@/api/api"; +import { apiPost, apiPostNoContent } from "@/api/api"; import { API_PATHS } from "@/api/api-paths"; import { handleApiResponse } from "@/api/handle-api-response"; import type { AuthUser } from "@/domain/auth/types/auth-user"; @@ -22,11 +22,11 @@ export const teamMemberInviteApi = { }, sendCode: async (token: string): Promise => { - await apiPost(API_PATHS.teamMemberInvite.sendCode, { token }); + await apiPostNoContent(API_PATHS.teamMemberInvite.sendCode, { token }); }, verifyCode: async ({ token, code }: { token: string; code: string }): Promise => { - await apiPost(API_PATHS.teamMemberInvite.verifyCode, { token, code }); + await apiPostNoContent(API_PATHS.teamMemberInvite.verifyCode, { token, code }); }, /** Finishes registration and stores the returned session exactly as login does. */ diff --git a/src/domain/team-members/api/team-members-api.ts b/src/domain/team-members/api/team-members-api.ts index bc6b5a70..c1462a69 100644 --- a/src/domain/team-members/api/team-members-api.ts +++ b/src/domain/team-members/api/team-members-api.ts @@ -1,4 +1,4 @@ -import { apiGet, apiPost, apiPut } from "@/api/api"; +import { apiGet, apiPost, apiPostNoContent, apiPut } from "@/api/api"; import { API_PATHS } from "@/api/api-paths"; import { handleApiResponse } from "@/api/handle-api-response"; import { @@ -42,6 +42,6 @@ export const teamMembersApi = { /** Emails a fresh invite link and revokes every earlier one for this pending member. */ resendInvite: async (id: string | number): Promise => { - await apiPost(API_PATHS.teamMember.invite(id)); + await apiPostNoContent(API_PATHS.teamMember.invite(id)); }, }; diff --git a/src/test/app/invite/invite-registration-page.test.tsx b/src/test/app/invite/invite-registration-page.test.tsx index c12a9ba4..fa8b35fc 100644 --- a/src/test/app/invite/invite-registration-page.test.tsx +++ b/src/test/app/invite/invite-registration-page.test.tsx @@ -32,7 +32,19 @@ const SESSION = { type Call = { path: string; url: string; body: Record }; -type ServerOptions = { sendCodeTooSoon?: boolean; resendTooSoon?: boolean }; +type ServerOptions = { + sendCodeTooSoon?: boolean; + resendTooSoon?: boolean; + /** The admin re-sent the invite after the code went out, revoking this token. */ + revokedBeforeVerify?: boolean; + rejectPasswordAtFinish?: boolean; +}; + +const INVALID_INVITE_BODY = { + code: "invalid_invite", + message: "This invite link is invalid or has expired. Ask your admin to send a new invite.", +}; +const PASSWORD_REJECTED_MESSAGE = "Choose a different password"; function json(status: number, body: unknown) { return new Response(JSON.stringify(body), { @@ -45,6 +57,8 @@ function json(status: number, body: unknown) { function installInviteServer({ sendCodeTooSoon = false, resendTooSoon = false, + revokedBeforeVerify = false, + rejectPasswordAtFinish = false, }: ServerOptions = {}) { const calls: Call[] = []; let sends = 0; @@ -55,11 +69,8 @@ function installInviteServer({ const body = (await request.json()) as Record; calls.push({ path, url: request.url, body }); - if (body.token !== TOKEN) { - return json(400, { - code: "invalid_invite", - message: "This invite link is invalid or has expired. Ask your admin to send a new invite.", - }); + if (body.token !== TOKEN || (revokedBeforeVerify && path === "verify-code")) { + return json(400, INVALID_INVITE_BODY); } switch (path) { @@ -84,7 +95,9 @@ function installInviteServer({ message: "Incorrect code — check your email and try again", }); case "complete": - return json(200, SESSION); + return rejectPasswordAtFinish + ? json(400, { code: "password_rejected", message: PASSWORD_REJECTED_MESSAGE }) + : json(200, SESSION); default: return json(404, {}); } @@ -131,7 +144,7 @@ async function completeCodeStep(user: ReturnType) { await screen.findByRole("heading", { name: "Review your info" }); } -describe("Invite registration", () => { +describe("Invite registration", { timeout: 15_000 }, () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); @@ -316,6 +329,36 @@ describe("Invite registration", () => { expect(calls.find((call) => call.path === "complete")?.body.phone).toBe(""); }); + it("ends the wizard with the generic message when the invite is revoked mid-way", async () => { + installInviteServer({ revokedBeforeVerify: true }); + const user = userEvent.setup(); + renderInvite(`#${TOKEN}`); + await completePasswordStep(user); + + await user.type(input(/^confirmation code/i), CODE); + await user.click(screen.getByRole("button", { name: "Continue" })); + + expect(await screen.findByRole("heading", { name: "Invite unavailable" })).toBeInTheDocument(); + expect(screen.getByText(INVALID_INVITE_BODY.message)).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Confirm your email" })).not.toBeInTheDocument(); + }); + + it("returns to step 1 with the server's message when the password is rejected at finish", async () => { + installInviteServer({ rejectPasswordAtFinish: true }); + const user = userEvent.setup(); + renderInvite(`#${TOKEN}`); + await completePasswordStep(user); + await completeCodeStep(user); + + await user.click(screen.getByRole("button", { name: /finish registration/i })); + + expect( + await screen.findByRole("heading", { name: "Create your password" }), + ).toBeInTheDocument(); + expect(screen.getByText(PASSWORD_REJECTED_MESSAGE)).toBeInTheDocument(); + expect(getAuthUser()).toBeNull(); + }); + it("sends the invite token only in request bodies, never in a URL", async () => { const calls = installInviteServer(); const user = userEvent.setup(); diff --git a/src/test/domain/team-members/invite-calls-without-body.test.ts b/src/test/domain/team-members/invite-calls-without-body.test.ts new file mode 100644 index 00000000..83986c95 --- /dev/null +++ b/src/test/domain/team-members/invite-calls-without-body.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { teamMemberInviteApi } from "@/domain/team-member-invite/api/team-member-invite-api"; +import { teamMembersApi } from "@/domain/team-members/api/team-members-api"; + +/** The server has already acted when it answers, so an empty success body must still succeed. */ +describe("invite calls that return nothing", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([200, 204])("treat an empty %i response as success", async (status) => { + vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(null, { status })); + + await expect(teamMembersApi.resendInvite("user-7")).resolves.toBeUndefined(); + await expect(teamMemberInviteApi.sendCode("token")).resolves.toBeUndefined(); + await expect( + teamMemberInviteApi.verifyCode({ token: "token", code: "246810" }), + ).resolves.toBeUndefined(); + }); +});