mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 09:43:12 +00:00
fix(team-members): cover mid-wizard invite loss and accept empty invite responses
This commit is contained in:
parent
976cd908e3
commit
ca42d6e59f
4 changed files with 77 additions and 13 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue