fix(team-members): cover mid-wizard invite loss and accept empty invite responses

This commit is contained in:
Alexandre Brandizzi 2026-09-25 12:33:37 -03:00
parent 976cd908e3
commit ca42d6e59f
4 changed files with 77 additions and 13 deletions

View file

@ -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<void> => {
await apiPost<unknown>(API_PATHS.teamMemberInvite.sendCode, { token });
await apiPostNoContent(API_PATHS.teamMemberInvite.sendCode, { token });
},
verifyCode: async ({ token, code }: { token: string; code: string }): Promise<void> => {
await apiPost<unknown>(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. */

View file

@ -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<void> => {
await apiPost<unknown>(API_PATHS.teamMember.invite(id));
await apiPostNoContent(API_PATHS.teamMember.invite(id));
},
};

View file

@ -32,7 +32,19 @@ const SESSION = {
type Call = { path: string; url: string; body: Record<string, unknown> };
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<string, unknown>;
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<typeof userEvent.setup>) {
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();

View file

@ -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();
});
});