Skip to content
File

Blob: tests/client/components/admin-login.test.tsx

typescript225 lines
1import { render, screen, waitFor } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { MemoryRouter } from "react-router-dom";
4import { beforeEach, describe, expect, it, vi } from "vitest";
5import { AdminLogin } from "@/client/components/admin-login";
6import { ApiError } from "@/client/lib/api";
7 
8const {
9 adminLogoutMock,
10 clearAdminBookmarkMock,
11 listAdminDomainsMock,
12 listAdminInboxesMock,
13 toastErrorMock,
14 toastInfoMock,
15 toastSuccessMock,
16} = vi.hoisted(() => ({
17 adminLogoutMock: vi.fn(),
18 clearAdminBookmarkMock: vi.fn(),
19 listAdminDomainsMock: vi.fn(),
20 listAdminInboxesMock: vi.fn(),
21 toastErrorMock: vi.fn(),
22 toastInfoMock: vi.fn(),
23 toastSuccessMock: vi.fn(),
24}));
25 
26vi.mock("@/client/components/admin/domain-manager", () => ({
27 DomainManager: ({ domains, onReload }: { domains: Array<{ domain: string }>; onReload: () => Promise<void> }) => {
28 const handleReload = async () => {
29 try {
30 await onReload();
31 toastSuccessMock("reload succeeded");
32 } catch (error) {
33 toastErrorMock(error instanceof Error ? error.message : "reload failed");
34 }
35 };
36 
37 return (
38 <div>
39 <div>Domain manager: {domains.map((domain) => domain.domain).join(",")}</div>
40 <button type="button" onClick={() => void handleReload()}>
41 Reload admin data
42 </button>
43 </div>
44 );
45 },
46}));
47 
48vi.mock("@/client/components/admin/permanent-inbox-list", () => ({
49 PermanentInboxList: ({ inboxes }: { inboxes: Array<{ address: string }> }) => (
50 <div>Permanent inboxes: {inboxes.map((inbox) => inbox.address).join(",")}</div>
51 ),
52}));
53 
54vi.mock("@/client/components/admin/temp-inbox-list", () => ({
55 TempInboxList: () => <div>Temp inbox list</div>,
56}));
57 
58vi.mock("@/client/components/toast", () => ({
59 toast: {
60 error: toastErrorMock,
61 info: toastInfoMock,
62 success: toastSuccessMock,
63 },
64}));
65 
66vi.mock("@/client/lib/api", async () => {
67 const actual = await vi.importActual<typeof import("@/client/lib/api")>("@/client/lib/api");
68 return {
69 ...actual,
70 adminLogout: adminLogoutMock,
71 clearAdminBookmark: clearAdminBookmarkMock,
72 listAdminDomains: listAdminDomainsMock,
73 listAdminInboxes: listAdminInboxesMock,
74 };
75});
76 
77function renderAdmin(initialEntries: string[] = ["/admin"]) {
78 return render(
79 <MemoryRouter initialEntries={initialEntries}>
80 <AdminLogin />
81 </MemoryRouter>,
82 );
83}
84 
85describe("AdminLogin", () => {
86 beforeEach(() => {
87 adminLogoutMock.mockReset();
88 clearAdminBookmarkMock.mockReset();
89 listAdminDomainsMock.mockReset();
90 listAdminInboxesMock.mockReset();
91 toastErrorMock.mockReset();
92 toastInfoMock.mockReset();
93 toastSuccessMock.mockReset();
94 });
95 
96 it("renders the sign-in card when the cookie session is missing", async () => {
97 listAdminDomainsMock.mockRejectedValue(new ApiError("Unauthorized", 401));
98 listAdminInboxesMock.mockRejectedValue(new ApiError("Unauthorized", 401));
99 
100 renderAdmin();
101 
102 await waitFor(() => {
103 expect(screen.getByRole("link", { name: /sign in with tessera/i }).getAttribute("href")).toBe(
104 "/api/public/admin/start",
105 );
106 });
107 expect(screen.queryByText(/Authenticated via tessera/)).toBeNull();
108 });
109 
110 it("renders the authenticated console when admin data loads", async () => {
111 listAdminDomainsMock.mockResolvedValue([
112 {
113 canDelete: true,
114 createdAt: "2026-04-15T00:00:00.000Z",
115 domain: "mail.test",
116 inboxCount: 4,
117 isActive: true,
118 },
119 ]);
120 listAdminInboxesMock.mockResolvedValue([
121 {
122 address: "admin@mail.test",
123 domain: "mail.test",
124 emailCount: 2,
125 localPart: "admin",
126 },
127 ]);
128 
129 renderAdmin();
130 
131 await waitFor(() => {
132 expect(screen.getByText("Authenticated via tessera")).not.toBeNull();
133 });
134 expect(screen.getByText("Domain manager: mail.test")).not.toBeNull();
135 expect(screen.getByText("Permanent inboxes: admin@mail.test")).not.toBeNull();
136 expect(screen.getByText("Temp inbox list")).not.toBeNull();
137 });
138 
139 it("surfaces the ?error= banner copy", async () => {
140 listAdminDomainsMock.mockRejectedValue(new ApiError("Unauthorized", 401));
141 listAdminInboxesMock.mockRejectedValue(new ApiError("Unauthorized", 401));
142 
143 renderAdmin(["/admin?error=not_operator"]);
144 
145 await waitFor(() => {
146 expect(screen.getByText(/not authorized for admin access/i)).not.toBeNull();
147 });
148 });
149 
150 it("logs out via the public logout endpoint and re-renders the sign-in card", async () => {
151 listAdminDomainsMock.mockResolvedValueOnce([]);
152 listAdminInboxesMock.mockResolvedValueOnce([]);
153 adminLogoutMock.mockResolvedValueOnce({ ok: true });
154 
155 renderAdmin();
156 
157 await waitFor(() => {
158 expect(screen.getByText("Authenticated via tessera")).not.toBeNull();
159 });
160 
161 const user = userEvent.setup();
162 await user.click(screen.getByRole("button", { name: /sign out/i }));
163 
164 await waitFor(() => {
165 expect(adminLogoutMock).toHaveBeenCalledTimes(1);
166 });
167 expect(clearAdminBookmarkMock).toHaveBeenCalledTimes(1);
168 expect(toastInfoMock).toHaveBeenCalledWith("Admin session cleared");
169 expect(screen.getByRole("link", { name: /sign in with tessera/i })).not.toBeNull();
170 });
171 
172 it("keeps the authenticated view when logout fails", async () => {
173 listAdminDomainsMock.mockResolvedValueOnce([]);
174 listAdminInboxesMock.mockResolvedValueOnce([]);
175 adminLogoutMock.mockRejectedValueOnce(new Error("logout failed"));
176 
177 renderAdmin();
178 
179 await waitFor(() => {
180 expect(screen.getByText("Authenticated via tessera")).not.toBeNull();
181 });
182 
183 const user = userEvent.setup();
184 await user.click(screen.getByRole("button", { name: /sign out/i }));
185 
186 await waitFor(() => {
187 expect(toastErrorMock).toHaveBeenCalledWith("logout failed");
188 });
189 expect(clearAdminBookmarkMock).not.toHaveBeenCalled();
190 expect(toastInfoMock).not.toHaveBeenCalledWith("Admin session cleared");
191 expect(screen.getByText("Authenticated via tessera")).not.toBeNull();
192 expect(screen.queryByRole("link", { name: /sign in with tessera/i })).toBeNull();
193 });
194 
195 it("propagates reload failures to child mutation handlers", async () => {
196 listAdminDomainsMock.mockResolvedValueOnce([
197 {
198 canDelete: true,
199 createdAt: "2026-04-15T00:00:00.000Z",
200 domain: "mail.test",
201 inboxCount: 4,
202 isActive: true,
203 },
204 ]);
205 listAdminInboxesMock.mockResolvedValueOnce([]);
206 
207 renderAdmin();
208 
209 await waitFor(() => {
210 expect(screen.getByText("Authenticated via tessera")).not.toBeNull();
211 });
212 
213 listAdminDomainsMock.mockRejectedValueOnce(new Error("reload failed"));
214 listAdminInboxesMock.mockResolvedValueOnce([]);
215 
216 const user = userEvent.setup();
217 await user.click(screen.getByRole("button", { name: /reload admin data/i }));
218 
219 await waitFor(() => {
220 expect(toastErrorMock).toHaveBeenCalledWith("reload failed");
221 });
222 expect(toastSuccessMock).not.toHaveBeenCalledWith("reload succeeded");
223 });
224});