Skip to content
Source
Sign inSign up

ExpoApi.test.ts

292 lines10.7 KB

1import { describe, expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as Redacted from "effect/Redacted";
8import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http";
9import * as HostProcess from "@t3tools/shared/HostProcess";
10​
11import { makeExpoApi, resolveExpoCredential, type ExpoCredential } from "./ExpoApi.ts";
12​
13const header = (credential: ExpoCredential) => ({
14 source: credential.source,
15 name: credential.header.name,
16 value: Redacted.value(credential.header.value),
17});
18​
19describe("Expo credentials", () => {
20 it.effect("prefer the saved token, then EXPO_TOKEN, then the eas login session", () =>
21 Effect.gen(function* () {
22 const fs = yield* FileSystem.FileSystem;
23 const path = yield* Path.Path;
24 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-expo-home-" });
25 yield* fs.makeDirectory(path.join(home, ".expo"));
26 yield* fs.writeFileString(
27 path.join(home, ".expo", "state.json"),
28 JSON.stringify({ auth: { sessionSecret: "session-secret", username: "julius" } }),
29 );
30 const resolve = (saved: string, env: NodeJS.ProcessEnv) =>
31 resolveExpoCredential(saved).pipe(
32 Effect.provideService(HostProcess.HomeDirectory, home),
33 Effect.provideService(HostProcess.Environment, env),
34 Effect.map(header),
35 );
36 expect(yield* resolve(" saved ", { EXPO_TOKEN: "env-token" })).toEqual({
37 source: "settings",
38 name: "authorization",
39 value: "Bearer saved",
40 });
41 expect(yield* resolve("", { EXPO_TOKEN: "env-token" })).toEqual({
42 source: "env",
43 name: "authorization",
44 value: "Bearer env-token",
45 });
46 expect(yield* resolve("", {})).toEqual({
47 source: "eas-login",
48 name: "expo-session",
49 value: "session-secret",
50 });
51 yield* fs.writeFileString(
52 path.join(home, ".expo", "state.json"),
53 JSON.stringify({ auth: null }),
54 );
55 const missing = yield* resolve("", {}).pipe(Effect.exit);
56 expect(Exit.isFailure(missing) && missing.cause.toString()).toContain("eas login");
57 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
58 );
59​
60 it.effect.each([
61 { case: "the redacted placeholder", saved: "\u2022\u2022\u2022\u2022\u2022\u2022", env: {} },
62 { case: "a pasted newline", saved: "abc\ndef", env: {} },
63 { case: "a malformed EXPO_TOKEN", saved: "", env: { EXPO_TOKEN: "abc def" } },
64 ])("refuses $case without echoing it", ({ saved, env }) =>
65 Effect.gen(function* () {
66 const error = yield* resolveExpoCredential(saved).pipe(
67 Effect.provideService(HostProcess.Environment, env),
68 Effect.flip,
69 );
70 expect(error._tag).toBe("ExpoInvalidCredentialError");
71 expect(error.message).not.toContain(saved || "abc def");
72 }).pipe(Effect.provide(NodeServices.layer)),
73 );
74});
75​
76describe("Expo API", () => {
77 const credential: ExpoCredential = {
78 source: "eas-login",
79 header: { name: "expo-session", value: Redacted.make("session-secret") },
80 };
81​
82 it.effect("sends the credential and reads session endpoints once the simulator is ready", () =>
83 Effect.gen(function* () {
84 const requests: Array<{ headers: Record<string, string>; body: unknown }> = [];
85 const http = HttpClient.make((request) =>
86 Effect.sync(() => {
87 const body =
88 request.body._tag === "Uint8Array"
89 ? (JSON.parse(new TextDecoder().decode(request.body.body)) as { variables: unknown })
90 : null;
91 requests.push({ headers: request.headers, body });
92 return HttpClientResponse.fromWeb(
93 request,
94 Response.json({
95 data: {
96 deviceRunSessions: {
97 byId: {
98 id: "session-1",
99 status: "IN_PROGRESS",
100 remoteConfig: {
101 __typename: "AgentDeviceRunSessionRemoteConfig",
102 agentDeviceRemoteSessionUrl: "https://agent.test",
103 agentDeviceRemoteSessionToken: "agent-token",
104 webPreviewToken: "preview-token",
105 previewApiUrl: "https://preview.test?token=preview-token",
106 },
107 },
108 },
109 },
110 }),
111 );
112 }),
113 );
114 const session = yield* makeExpoApi(http, credential).session("session-1");
115 expect(requests[0]?.headers["expo-session"]).toBe("session-secret");
116 expect(requests[0]?.body).toMatchObject({ variables: { id: "session-1" } });
117 expect(session.status).toBe("IN_PROGRESS");
118 expect(session.endpoints?.agentDevice.url).toBe("https://agent.test");
119 expect(Redacted.value(session.endpoints!.agentDevice.token)).toBe("agent-token");
120 expect(session.endpoints?.preview?.url).toBe("https://preview.test?token=preview-token");
121 expect(Redacted.value(session.endpoints!.preview!.token)).toBe("preview-token");
122 }),
123 );
124​
125 it.effect("keeps the credential out of errors when Expo is unreachable", () =>
126 Effect.gen(function* () {
127 const http = HttpClient.make((request) =>
128 Effect.fail(
129 new HttpClientError.HttpClientError({
130 reason: new HttpClientError.TransportError({
131 request,
132 description: "connection reset",
133 }),
134 }),
135 ),
136 );
137 const error = yield* makeExpoApi(http, credential).viewer.pipe(Effect.flip);
138 expect(error.message).toContain("Expo's API is unreachable");
139 expect(JSON.stringify(error)).not.toContain("session-secret");
140 expect(error.cause).toBeUndefined();
141 // No answer: a mutation may have run.
142 expect(error.rejected).toBe(false);
143 }),
144 );
145​
146 it.effect("reports GraphQL errors and rejected credentials as Expo failures", () =>
147 Effect.gen(function* () {
148 const respond = (response: Response) =>
149 HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, response)));
150 const graphQl = yield* makeExpoApi(
151 respond(Response.json({ errors: [{ message: "Feature not enabled" }] })),
152 credential,
153 )
154 .availability("app-1")
155 .pipe(Effect.flip);
156 expect(graphQl.message).toBe(
157 "Expo could not check EAS Simulator access: Feature not enabled",
158 );
159 const rejected = yield* makeExpoApi(respond(new Response("no", { status: 401 })), credential)
160 .stop("session-1")
161 .pipe(Effect.flip);
162 expect(rejected.message).toBe(
163 "Expo could not stop a simulator session: the eas login session was rejected (HTTP 401)",
164 );
165 const serverError = yield* makeExpoApi(
166 respond(Response.json({ errors: [{ message: "Internal server error" }] }, { status: 502 })),
167 credential,
168 )
169 .stop("session-1")
170 .pipe(Effect.flip);
171 expect(serverError.message).toBe(
172 "Expo could not stop a simulator session: Internal server error",
173 );
174 // What Expo answers a refused request with: Apollo's default code, the
175 // user's fault, and null data.
176 const expoRefusal = yield* makeExpoApi(
177 respond(
178 Response.json({
179 errors: [
180 {
181 message: "Experience with id 0000 does not exist.",
182 extensions: {
183 code: "INTERNAL_SERVER_ERROR",
184 errorCode: "EXPERIENCE_NOT_FOUND",
185 errorType: "USER",
186 isTransient: false,
187 },
188 },
189 ],
190 data: null,
191 }),
192 ),
193 credential,
194 )
195 .stop("session-1")
196 .pipe(Effect.flip);
197 const internalError = yield* makeExpoApi(
198 respond(
199 Response.json({
200 errors: [
201 {
202 message: "Something went wrong",
203 extensions: { errorCode: "INTERNAL_SERVER_ERROR" },
204 },
205 ],
206 }),
207 ),
208 credential,
209 )
210 .stop("session-1")
211 .pipe(Effect.flip);
212 const transient = yield* makeExpoApi(
213 respond(
214 Response.json({
215 errors: [
216 {
217 message: "Something went wrong",
218 extensions: { errorCode: "INTERNAL_SERVER_ERROR", isTransient: true },
219 },
220 ],
221 }),
222 ),
223 credential,
224 )
225 .stop("session-1")
226 .pipe(Effect.flip);
227 const ran = yield* makeExpoApi(
228 respond(
229 Response.json({
230 data: { deviceRunSession: { stopDeviceRunSession: null } },
231 errors: [
232 { message: "Session is already stopping", extensions: { errorCode: "CONFLICT" } },
233 ],
234 }),
235 ),
236 credential,
237 )
238 .stop("session-1")
239 .pipe(Effect.flip);
240 const fault = yield* makeExpoApi(
241 respond(
242 Response.json({
243 errors: [{ message: "Something went wrong", extensions: { errorType: "INTERNAL" } }],
244 }),
245 ),
246 credential,
247 )
248 .stop("session-1")
249 .pipe(Effect.flip);
250 const echoed = yield* makeExpoApi(
251 respond(
252 Response.json({
253 errors: [{ message: `Session session-secret is not yours (${"x".repeat(400)})` }],
254 }),
255 ),
256 credential,
257 )
258 .stop("session-1")
259 .pipe(Effect.flip);
260 // A message that echoes the credential loses it, and is bounded.
261 expect(echoed.message).toContain("Session [redacted] is not yours");
262 expect(echoed.message).not.toContain("session-secret");
263 expect(echoed.message.length).toBeLessThan(400);
264 const userError = yield* makeExpoApi(
265 respond(
266 Response.json({
267 errors: [
268 { message: "Not allowed", extensions: { errorType: "USER", errorCode: "FORBIDDEN" } },
269 ],
270 }),
271 ),
272 credential,
273 )
274 .stop("session-1")
275 .pipe(Effect.flip);
276 // Expo answered and refused the first three: nothing was done. GraphQL
277 // errors at a server error, a transient, internal or other non-user
278 // error, or errors beside data, may come from a request that ran.
279 expect([
280 graphQl.rejected,
281 rejected.rejected,
282 userError.rejected,
283 expoRefusal.rejected,
284 serverError.rejected,
285 internalError.rejected,
286 transient.rejected,
287 fault.rejected,
288 ran.rejected,
289 ]).toEqual([true, true, true, true, false, false, false, false, false]);
290 }),
291 );
292});