Skip to content
Source
Sign inSign up

ServerUpdateAction.browser.test.tsx

240 lines7.6 KB

1import { RegistryContext } from "@effect/atom-react";
2import { AuthSessionState, type EnvironmentId } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";
4import * as Schema from "effect/Schema";
5import { AsyncResult, Atom } from "effect/reactivity";
6import type { ReactNode } from "react";
7import { describe, expect, it, vi } from "vite-plus/test";
8​
9import { act, render, type Screen } from "../../test/render";
10​
11type SessionResult = AsyncResult.AsyncResult<AuthSessionState, Error>;
12const testState = vi.hoisted(() => ({
13 updateServer: vi.fn(),
14 toast: vi.fn(),
15 clipboard: vi.fn(),
16 sessionAtom: null as unknown,
17 serverVersions: new Map<string, string>(),
18}));
19​
20vi.mock(import("~/hooks/useCopyToClipboard"), async (original) => ({
21 ...(await original()),
22 useCopyToClipboard: ((options: { onCopy: (context: { command: string }) => void }) => ({
23 copyToClipboard: (command: string, context: { command: string }) => {
24 testState.clipboard(command);
25 options.onCopy(context);
26 },
27 isCopied: false,
28 })) as never,
29}));
30vi.mock(import("~/state/session"), async (original) => {
31 const actual = await original();
32 return {
33 ...actual,
34 environmentSession: {
35 ...actual.environmentSession,
36 sessionStateAtom: (() => testState.sessionAtom) as never,
37 },
38 };
39});
40vi.mock(import("~/state/entities"), async (original) => ({
41 ...(await original()),
42 useServerConfigs: (() =>
43 new Map(
44 [...testState.serverVersions].map(([id, serverVersion]) => [
45 id,
46 { environment: { serverVersion } },
47 ]),
48 )) as never,
49}));
50vi.mock(import("~/state/use-atom-command"), async (original) => ({
51 ...(await original()),
52 useAtomCommand: (() => testState.updateServer) as never,
53}));
54vi.mock(import("./ui/toast"), async (original) => {
55 const actual = await original();
56 return { ...actual, toastManager: { ...actual.toastManager, add: testState.toast } };
57});
58​
59import { resetConfirmDialogForTests } from "~/confirmDialog";
60import { appAtomRegistry } from "~/rpc/atomRegistry";
61import {
62 ServerUpdateAction,
63 ServerUpdatesAction,
64 type ServerUpdateTarget,
65} from "./ServerUpdateAction";
66​
67const decodeSessionState = Schema.decodeUnknownSync(AuthSessionState);
68​
69const legacyAuth = {
70 policy: "remote-reachable",
71 bootstrapMethods: ["one-time-token"],
72 sessionMethods: ["bearer-access-token"],
73 sessionCookieName: "t3_session",
74} as const;
75​
76const currentSession = {
77 authenticated: true,
78 scopes: ["environment:maintain"],
79 auth: {
80 ...legacyAuth,
81 serverUpdateScope: "environment:maintain",
82 },
83} as const satisfies AuthSessionState;
84​
85const success = (method: "boot-service" | "desktop-app" = "boot-service", version = "0.0.31") =>
86 AsyncResult.success({ targetVersion: version, method });
87​
88let sessionAtom: Atom.Writable<SessionResult>;
89​
90function setSession(session: SessionResult) {
91 appAtomRegistry.set(sessionAtom, session);
93​
94function setup(): Disposable {
95 testState.updateServer.mockReset();
96 testState.toast.mockReset();
97 testState.clipboard.mockReset();
98 testState.serverVersions.clear();
99 sessionAtom = Atom.make<SessionResult>(AsyncResult.success(currentSession));
100 testState.sessionAtom = sessionAtom;
101 resetConfirmDialogForTests();
102 return { [Symbol.dispose]: resetConfirmDialogForTests };
104​
105function renderUpdate(element: ReactNode) {
106 return render(
107 <RegistryContext.Provider value={appAtomRegistry}>{element}</RegistryContext.Provider>,
108 );
110​
111function button(screen: Screen, name?: string) {
112 const buttons = [...screen.container.querySelectorAll("button")];
113 return name === undefined
114 ? buttons[0]
115 : buttons.find((element) => element.textContent?.trim() === name);
117​
118async function click(screen: Screen, name?: string) {
119 await act(async () => button(screen, name)!.click());
121​
122function renderAction(props: Partial<Parameters<typeof ServerUpdateAction>[0]> = {}) {
123 return renderUpdate(
124 <ServerUpdateAction
125 environmentId={"env-test" as EnvironmentId}
126 serverLabel="Test server"
127 selfUpdate="boot-service"
128 targetVersion="0.0.31"
129 {...props}
130 />,
131 );
133​
134describe("ServerUpdateAction", () => {
135 it.each([
136 { serverUpdateScope: undefined, scopes: ["orchestration:operate"], allowed: true },
137 { serverUpdateScope: undefined, scopes: ["orchestration:read"], allowed: false },
138 {
139 serverUpdateScope: "environment:maintain",
140 scopes: ["orchestration:operate"],
141 allowed: false,
142 },
143 {
144 serverUpdateScope: "environment:maintain",
145 scopes: ["environment:maintain"],
146 allowed: true,
147 },
148 ] as const)(
149 "uses the advertised update scope $serverUpdateScope with grant $scopes",
150 async ({ serverUpdateScope, scopes, allowed }) => {
151 using _state = setup();
152 setSession(
153 AsyncResult.success(
154 decodeSessionState({
155 ...currentSession,
156 scopes,
157 auth: {
158 ...legacyAuth,
159 ...(serverUpdateScope === undefined ? {} : { serverUpdateScope }),
160 },
161 }),
162 ),
163 );
164 testState.updateServer.mockResolvedValue(success());
165​
166 await using screen = await renderAction();
167 expect(button(screen, "Update")!.disabled).toBe(!allowed);
168 await click(screen, "Update");
169​
170 expect(testState.updateServer).toHaveBeenCalledTimes(allowed ? 1 : 0);
171 },
172 );
173​
174 it("disables the action once maintenance access is removed", async () => {
175 using _state = setup();
176 await using screen = await renderAction();
177 await act(() => setSession(AsyncResult.success({ ...currentSession, scopes: [] })));
178 expect(button(screen, "Update")!.disabled).toBe(true);
179 await click(screen, "Update");
180 expect(testState.updateServer).not.toHaveBeenCalled();
181 });
182});
183​
184describe("ServerUpdatesAction", () => {
185 const targets: ReadonlyArray<ServerUpdateTarget> = [
186 {
187 environmentId: "batch-a" as EnvironmentId,
188 serverLabel: "Laptop",
189 selfUpdate: "boot-service",
190 targetVersion: "0.0.31",
191 },
192 {
193 environmentId: "batch-b" as EnvironmentId,
194 serverLabel: "Office",
195 selfUpdate: "respawn",
196 targetVersion: "0.0.31",
197 },
198 {
199 environmentId: "batch-c" as EnvironmentId,
200 serverLabel: "Manual",
201 selfUpdate: null,
202 targetVersion: "0.0.31",
203 },
204 ];
205​
206 const mount = (batch = targets) => renderUpdate(<ServerUpdatesAction targets={batch} />);
207​
208 it("names a failed machine while letting the other machine complete", async () => {
209 using _state = setup();
210 testState.updateServer
211 .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Download failed"))))
212 .mockResolvedValueOnce(success());
213 await using screen = await mount();
214 await click(screen, "Update all");
215​
216 expect(testState.updateServer).toHaveBeenCalledTimes(2);
217 expect(testState.toast).toHaveBeenCalledWith({
218 type: "error",
219 title: "Laptop update failed",
220 description: "Download failed",
221 });
222 expect(testState.toast).toHaveBeenCalledWith(
223 expect.objectContaining({ type: "success", title: "Office updated" }),
224 );
225 expect(button(screen, "Update all")!.disabled).toBe(false);
226 });
227​
228 it("skips a machine whose update would cross Stable and Nightly", async () => {
229 using _state = setup();
230 testState.serverVersions.set("batch-a", "0.0.30-nightly.20261001.1");
231 testState.updateServer.mockResolvedValue(success());
232 await using screen = await mount();
233 await click(screen, "Update all");
234​
235 expect(testState.updateServer).toHaveBeenCalledTimes(1);
236 expect(testState.toast).toHaveBeenCalledWith(
237 expect.objectContaining({ type: "success", title: "Office updated" }),
238 );
239 });
240});